From db22eaa70fbcb87e221ce1aa0e5b95479634f3e3 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Fri, 5 Jan 2024 03:43:26 +0200 Subject: [PATCH] feat: channel subpage view #1 --- lib/controller/edit_delete_controller.dart | 19 +- lib/core/translations/keys.dart | 1 + lib/ui/widgets/artwork.dart | 10 + .../youtube_channel_controller.dart | 192 +++++++ .../controller/youtube_controller.dart | 10 +- .../controller/youtube_import_controller.dart | 2 +- lib/youtube/pages/yt_channel_subpage.dart | 353 +++++++++++++ lib/youtube/pages/yt_channels_page.dart | 474 +++++++----------- lib/youtube/widgets/yt_card.dart | 10 +- lib/youtube/widgets/yt_channel_card.dart | 12 +- lib/youtube/widgets/yt_subscribe_buttons.dart | 45 ++ lib/youtube/widgets/yt_thumbnail.dart | 8 +- lib/youtube/widgets/yt_video_card.dart | 7 +- .../widgets/yt_videos_actions_bar.dart | 225 +++++++++ lib/youtube/youtube_miniplayer.dart | 183 +++---- 15 files changed, 1145 insertions(+), 406 deletions(-) create mode 100644 lib/youtube/controller/youtube_channel_controller.dart create mode 100644 lib/youtube/pages/yt_channel_subpage.dart create mode 100644 lib/youtube/widgets/yt_subscribe_buttons.dart create mode 100644 lib/youtube/widgets/yt_videos_actions_bar.dart diff --git a/lib/controller/edit_delete_controller.dart b/lib/controller/edit_delete_controller.dart index 662b3745..d9174d1b 100644 --- a/lib/controller/edit_delete_controller.dart +++ b/lib/controller/edit_delete_controller.dart @@ -54,7 +54,7 @@ class EditDeleteController { if (!await requestManageStoragePermission()) { return null; } - final saveDir = await Directory(AppDirs.SAVED_ARTWORKS).create(); + final saveDir = await Directory(AppDirs.SAVED_ARTWORKS).create(recursive: true); final saveDirPath = saveDir.path; final newPath = "$saveDirPath${Platform.pathSeparator}${track.filenameWOExt}.png"; final imgFiles = await Indexer.inst.extractTracksArtworks( @@ -72,6 +72,23 @@ class EditDeleteController { } } + /// returns save directory path if saved successfully + Future saveImageToStorage(File imageFile) async { + if (!await requestManageStoragePermission()) { + return null; + } + final saveDir = await Directory(AppDirs.SAVED_ARTWORKS).create(recursive: true); + final saveDirPath = saveDir.path; + final newPath = "$saveDirPath${Platform.pathSeparator}${imageFile.path.getFilenameWOExt}.png"; + try { + await imageFile.copy(newPath); + return saveDirPath; + } catch (e) { + printy(e, isError: true); + return null; + } + } + Future updateTrackPathInEveryPartOfNamida(Track oldTrack, String newPath) async { final newtrlist = await Indexer.inst.convertPathToTrack([newPath]); if (newtrlist.isEmpty) return; diff --git a/lib/core/translations/keys.dart b/lib/core/translations/keys.dart index b3eb520d..1a1a74dd 100644 --- a/lib/core/translations/keys.dart +++ b/lib/core/translations/keys.dart @@ -308,6 +308,7 @@ abstract class LanguageKeys { String get LINK => _getKey('LINK'); String get LIST_OF_FOLDERS => _getKey('LIST_OF_FOLDERS'); String get LOADING_FILE => _getKey('LOADING_FILE'); + String get LOAD_ALL => _getKey('LOAD_ALL'); String get LOCAL => _getKey('LOCAL'); String get LOCAL_VIDEO_MATCHING => _getKey('LOCAL_VIDEO_MATCHING'); String get LOST_MEMORIES => _getKey('LOST_MEMORIES'); diff --git a/lib/ui/widgets/artwork.dart b/lib/ui/widgets/artwork.dart index efa2f233..5535dcc4 100644 --- a/lib/ui/widgets/artwork.dart +++ b/lib/ui/widgets/artwork.dart @@ -41,6 +41,7 @@ class ArtworkWidget extends StatefulWidget { final bool displayIcon; final IconData icon; final bool isCircle; + final VoidCallback? onError; const ArtworkWidget({ required super.key, @@ -67,6 +68,7 @@ class ArtworkWidget extends StatefulWidget { this.displayIcon = true, this.icon = Broken.musicnote, this.isCircle = false, + this.onError, }); @override @@ -253,6 +255,14 @@ class _ArtworkWidgetState extends State with LoadingItemsDelayMix ); }), errorBuilder: (context, error, stackTrace) { + if (error.toString().contains('Invalid image data')) { + final fp = widget.path; + if (fp != null) { + File(fp).tryDeleting(); + FileImage(File(fp)).evict(); + } + } + widget.onError?.call(); return getStockWidget( stackWithOnTopWidgets: false, ); diff --git a/lib/youtube/controller/youtube_channel_controller.dart b/lib/youtube/controller/youtube_channel_controller.dart new file mode 100644 index 00000000..ce7e2fb9 --- /dev/null +++ b/lib/youtube/controller/youtube_channel_controller.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; + +import 'package:namida/controller/connectivity.dart'; +import 'package:namida/controller/current_color.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/ui/widgets/custom_widgets.dart'; +import 'package:namida/youtube/class/youtube_subscription.dart'; +import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; + +enum VideosSorting { + date, + views, + duration, +} + +mixin YoutubeChannelController on State { + late final ScrollController uploadsScrollController = ScrollController(); + YoutubeSubscription? channel; + final streamsList = []; + ({DateTime oldest, DateTime newest})? streamsPeakDates; + + late final _defaultSorting = VideosSorting.date; + late final _defaultSortingByTop = true; + late final sorting = _defaultSorting.obs; + late final sortingByTop = _defaultSortingByTop.obs; + + bool isLoadingInitialStreams = true; + final isLoadingMoreUploads = false.obs; + final lastLoadingMoreWasEmpty = false.obs; + + late final sortWidget = SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ...VideosSorting.values.map( + (e) { + final details = sortToTextAndIcon(e); + final enabled = sorting.value == e; + final itemsColor = enabled ? Colors.white.withOpacity(0.8) : null; + return NamidaInkWell( + animationDurationMS: 200, + borderRadius: 6.0, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + margin: const EdgeInsets.symmetric(horizontal: 3.0), + bgColor: enabled ? CurrentColor.inst.color : context.theme.cardColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + enabled + ? Obx( + () => StackedIcon( + baseIcon: details.$2, + secondaryIcon: sortingByTop.value ? Broken.arrow_down_2 : Broken.arrow_up_3, + iconSize: 20.0, + secondaryIconSize: 10.0, + blurRadius: 4.0, + baseIconColor: itemsColor, + // secondaryIconColor: enabled ? context.theme.colorScheme.background : null, + ), + ) + : Icon( + details.$2, + size: 20.0, + color: null, + ), + const SizedBox(width: 4.0), + Text( + details.$1, + style: context.textTheme.displayMedium?.copyWith(color: itemsColor), + ), + ], + ), + onTap: () => setState( + () => sortStreams(sort: e, sortingByTop: enabled ? !sortingByTop.value : null), + ), + ); + }, + ), + ], + ), + ), + ); + + @override + void dispose() { + uploadsScrollController.dispose(); + isLoadingMoreUploads.close(); + lastLoadingMoreWasEmpty.close(); + sorting.close(); + super.dispose(); + } + + void updatePeakDates(List streams) { + int oldest = (streamsPeakDates?.oldest ?? DateTime.now()).millisecondsSinceEpoch; + int newest = (streamsPeakDates?.newest ?? DateTime(0)).millisecondsSinceEpoch; + streams.loop((e, _) { + final d = e.date; + if (d != null) { + final ms = d.millisecondsSinceEpoch; + if (ms < oldest) { + oldest = ms; + } else if (ms > newest) { + newest = ms; + } + } + }); + streamsPeakDates = (oldest: DateTime.fromMillisecondsSinceEpoch(oldest), newest: DateTime.fromMillisecondsSinceEpoch(newest)); + } + + Future fetchChannelStreams(YoutubeSubscription sub) async { + final st = await YoutubeController.inst.getChannelStreams(sub.channelID); + updatePeakDates(st); + YoutubeSubscriptionsController.inst.refreshLastFetchedTime(sub.channelID); + setState(() { + isLoadingInitialStreams = false; + if (sub.channelID == channel?.channelID) { + streamsList.addAll(st); + trySortStreams(); + } + }); + } + + Future fetchStreamsNextPage(YoutubeSubscription? sub) async { + if (isLoadingMoreUploads.value) return; + if (lastLoadingMoreWasEmpty.value) return; + + isLoadingMoreUploads.value = true; + final st = await YoutubeController.inst.getChannelStreamsNextPage(); + updatePeakDates(st); + isLoadingMoreUploads.value = false; + if (st.isEmpty) { + if (ConnectivityController.inst.hasConnection) lastLoadingMoreWasEmpty.value = true; + return; + } + if (sub?.channelID == channel?.channelID) { + setState(() { + streamsList.addAll(st); + trySortStreams(); + }); + } + } + + void trySortStreams() { + if (sorting.value != _defaultSorting || sortingByTop.value != _defaultSortingByTop) { + sortStreams(jumpToZero: false); + } + } + + void sortStreams({List? streams, VideosSorting? sort, bool? sortingByTop, bool jumpToZero = true}) { + sort ??= sorting.value; + streams ??= streamsList; + sortingByTop ??= this.sortingByTop.value; + switch (sort) { + case VideosSorting.date: + sortingByTop ? streams.sortByReverse((e) => e.date ?? DateTime(0)) : streams.sortBy((e) => e.date ?? DateTime(0)); + break; + + case VideosSorting.views: + sortingByTop ? streams.sortByReverse((e) => e.viewCount ?? 0) : streams.sortBy((e) => e.viewCount ?? 0); + break; + + case VideosSorting.duration: + sortingByTop ? streams.sortByReverse((e) => e.duration ?? Duration.zero) : streams.sortBy((e) => e.duration ?? Duration.zero); + break; + + default: + null; + } + sorting.value = sort; + this.sortingByTop.value = sortingByTop; + + if (jumpToZero && uploadsScrollController.hasClients) uploadsScrollController.jumpTo(0); + } + + (String, IconData) sortToTextAndIcon(VideosSorting sort) { + switch (sort) { + case VideosSorting.date: + return (lang.DATE, Broken.calendar); + case VideosSorting.views: + return (lang.VIEWS, Broken.eye); + case VideosSorting.duration: + return (lang.DURATION, Broken.timer_1); + } + } +} diff --git a/lib/youtube/controller/youtube_controller.dart b/lib/youtube/controller/youtube_controller.dart index 842ea36d..a7f2c255 100644 --- a/lib/youtube/controller/youtube_controller.dart +++ b/lib/youtube/controller/youtube_controller.dart @@ -273,10 +273,12 @@ class YoutubeController { if (checkFromStorage) { final file = File('${AppDirs.YT_METADATA_TEMP}$id.txt'); final res = file.readAsJsonSync(); - try { - final strInfo = StreamInfoItem.fromMap(res); - return strInfo; - } catch (_) {} + if (res != null) { + try { + final strInfo = StreamInfoItem.fromMap(res); + return strInfo; + } catch (_) {} + } } return null; } diff --git a/lib/youtube/controller/youtube_import_controller.dart b/lib/youtube/controller/youtube_import_controller.dart index 2301572b..ab2e183b 100644 --- a/lib/youtube/controller/youtube_import_controller.dart +++ b/lib/youtube/controller/youtube_import_controller.dart @@ -80,7 +80,7 @@ class YoutubeImportController { res.loop((e, index) { final valInMap = YoutubeSubscriptionsController.inst.subscribedChannels[e.id]; YoutubeSubscriptionsController.inst.subscribedChannels[e.id] = YoutubeSubscription( - title: valInMap != null && valInMap.title == '' ? e.title : valInMap?.title, + title: valInMap != null && valInMap.title == '' ? e.title : valInMap?.title ?? e.title, channelID: e.id, subscribed: true, lastFetched: valInMap?.lastFetched, diff --git a/lib/youtube/pages/yt_channel_subpage.dart b/lib/youtube/pages/yt_channel_subpage.dart new file mode 100644 index 00000000..20c195ad --- /dev/null +++ b/lib/youtube/pages/yt_channel_subpage.dart @@ -0,0 +1,353 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:photo_view/photo_view.dart'; + +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/dimensions.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/ui/widgets/custom_widgets.dart'; +import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/class/youtube_subscription.dart'; +import 'package:namida/youtube/controller/youtube_channel_controller.dart'; +import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; +import 'package:namida/youtube/widgets/yt_subscribe_buttons.dart'; +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'; + +class YTChannelSubpage extends StatefulWidget { + final String channelID; + final YoutubeSubscription? sub; + final YoutubeChannel? channel; + const YTChannelSubpage({super.key, required this.channelID, this.sub, this.channel}); + + @override + State createState() => _YTChannelSubpageState(); +} + +class _YTChannelSubpageState extends State with YoutubeChannelController { + late final YoutubeSubscription ch = YoutubeSubscriptionsController.inst.subscribedChannels[widget.channelID] ?? + YoutubeSubscription( + channelID: widget.channelID.split('/').last, + subscribed: false, + ); + + YoutubeChannel? _channelInfo; + bool _canKeepLoadingMore = false; + + @override + void initState() { + channel = ch; + fetchChannelStreams(ch); + + final channelUrl = 'https://www.youtube.com/channel/${ch.channelID}'; + + _channelInfo = widget.channel ?? YoutubeController.inst.fetchChannelDetailsFromCacheSync(ch.channelID); + // -- always get new info. + YoutubeController.inst.fetchChannelDetails(channelUrl, forceRequest: true).then( + (value) { + if (value != null) setState(() => _channelInfo = value); + }, + ); + + super.initState(); + } + + void _onImageTap(BuildContext context, String channelID, String imageUrl, bool isBanner) { + File? file; + if (!isBanner) { + file = ThumbnailManager.inst.imageUrlToCacheFile(id: null, url: channelID); + } + file ??= ThumbnailManager.inst.imageUrlToCacheFile(id: null, url: imageUrl); + if (file == null) return; + NamidaNavigator.inst.navigateDialog( + scale: 1.0, + blackBg: true, + dialog: GestureDetector( + onLongPress: () async { + final saveDirPath = await EditDeleteController.inst.saveImageToStorage(file!); + String title = lang.COPIED_ARTWORK; + String subtitle = '${lang.SAVED_IN} $saveDirPath'; + // ignore: use_build_context_synchronously + Color snackColor = context.theme.colorScheme.background; + + if (saveDirPath == null) { + title = lang.ERROR; + subtitle = lang.COULDNT_SAVE_IMAGE; + snackColor = Colors.red; + } + snackyy( + title: title, + message: subtitle, + leftBarIndicatorColor: snackColor, + margin: EdgeInsets.zero, + top: false, + borderRadius: 0, + ); + }, + child: PhotoView( + heroAttributes: PhotoViewHeroAttributes(tag: '${isBanner}_${channelID}_$imageUrl'), + gaplessPlayback: true, + tightMode: true, + minScale: PhotoViewComputedScale.contained, + loadingBuilder: (context, event) => const SizedBox(), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + filterQuality: FilterQuality.high, + imageProvider: FileImage(file), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final thumbnailWidth = context.width * 0.28; + final thumbnailHeight = thumbnailWidth * 9 / 16; + final thumbnailItemExtent = thumbnailHeight + 8.0 * 2; + final channelID = _channelInfo?.id ?? ch.channelID; + final avatarUrl = _channelInfo?.avatarUrl ?? _channelInfo?.thumbnailUrl ?? ch.channelID; + final bannerUrl = _channelInfo?.bannerUrl ?? _channelInfo?.bannerUrl; + final subsCount = _channelInfo?.subscriberCount; + final streamsCount = _channelInfo?.streamCount; + final dummyStreamsCount = streamsCount == null || streamsCount < 0; + const bannerHeight = 69.0; + + return BackgroundWrapper( + child: Column( + children: [ + Stack( + children: [ + if (bannerUrl != null) + GestureDetector( + onTap: () => _onImageTap(context, channelID, bannerUrl, true), + child: NamidaHero( + tag: 'true_${channelID}_$bannerUrl', + child: YoutubeThumbnail( + key: Key('${channelID}_$bannerUrl'), + width: context.width, + compressed: false, + isImportantInCache: false, + channelUrl: bannerUrl, + borderRadius: 0, + displayFallbackIcon: false, + height: bannerHeight, + ), + ), + ), + Padding( + padding: (bannerUrl == null ? EdgeInsets.zero : const EdgeInsets.only(top: bannerHeight * 0.95)), + child: Row( + children: [ + const SizedBox(width: 12.0), + Transform.translate( + offset: bannerUrl == null ? const Offset(0, 0) : const Offset(0, -bannerHeight * 0.1), + child: GestureDetector( + onTap: () => _onImageTap(context, channelID, avatarUrl, false), + child: NamidaHero( + tag: 'false_${channelID}_$avatarUrl', + child: YoutubeThumbnail( + key: Key('${channelID}_$avatarUrl'), + width: context.width * 0.14, + isImportantInCache: true, + channelUrl: avatarUrl, + channelIDForHQImage: ch.channelID, + isCircle: true, + compressed: false, + ), + ), + ), + ), + const SizedBox(width: 6.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2.0), + child: Text( + _channelInfo?.name ?? ch.title, + style: context.textTheme.displayLarge, + ), + ), + const SizedBox(height: 4.0), + Text( + [ + subsCount?.formatDecimalShort() ?? '?', + (subsCount ?? 0) < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, + ].join(' '), + style: context.textTheme.displayMedium?.copyWith( + fontSize: 12.0.multipliedFontScale, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 4.0), + YTSubscribeButton(channelIDOrURL: channelID), + const SizedBox(width: 12.0), + ], + ), + ), + ], + ), + const SizedBox(height: 4.0), + Row( + children: [ + const SizedBox(width: 4.0), + Expanded(child: sortWidget), + const SizedBox(width: 4.0), + Obx( + () => NamidaInkWellButton( + animationDurationMS: 100, + sizeMultiplier: 0.95, + borderRadius: 8.0, + icon: Broken.task_square, + text: lang.LOAD_ALL, + enabled: !isLoadingMoreUploads.value, + disableWhenLoading: false, + onTap: () async { + _canKeepLoadingMore = !_canKeepLoadingMore; + while (_canKeepLoadingMore && !lastLoadingMoreWasEmpty.value && ConnectivityController.inst.hasConnection) { + await fetchStreamsNextPage(ch); + } + }, + ), + ), + const SizedBox(width: 4.0), + ], + ), + const SizedBox(height: 10.0), + Row( + children: [ + const SizedBox(width: 8.0), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4.0, + children: [ + NamidaInkWell( + borderRadius: 6.0, + decoration: BoxDecoration( + border: Border.all(color: context.theme.colorScheme.secondary.withOpacity(0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 3.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Broken.video_square, size: 16.0), + const SizedBox(width: 4.0), + Text( + "${streamsList.length} / ${dummyStreamsCount ? '?' : streamsCount}", + style: context.textTheme.displayMedium, + ), + ], + ), + ), + const SizedBox(width: 4.0), + if (streamsPeakDates != null) + NamidaInkWell( + borderRadius: 6.0, + decoration: BoxDecoration( + border: Border.all(color: context.theme.colorScheme.secondary.withOpacity(0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), + child: Text( + "${streamsPeakDates!.oldest.millisecondsSinceEpoch.dateFormattedOriginal} (${Jiffy.parseFromDateTime(streamsPeakDates!.oldest).fromNow()})", + style: context.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + const SizedBox(width: 4.0), + YTVideosActionBar( + title: _channelInfo?.name ?? ch.title, + url: _channelInfo?.url ?? '', + barOptions: const YTVideosActionBarOptions( + addToPlaylist: false, + playLast: false, + ), + videosCallback: () => streamsList + .map((e) => YoutubeID( + id: e.id ?? '', + playlistID: null, + )) + .toList(), + infoLookupCallback: () { + final m = {}; + streamsList.loop((e, index) { + m[e.id ?? ''] = e; + }); + return m; + }, + ), + const SizedBox(width: 8.0), + ], + ), + const SizedBox(height: 8.0), + Expanded( + child: NamidaScrollbar( + controller: uploadsScrollController, + child: isLoadingInitialStreams + ? ShimmerWrapper( + shimmerEnabled: true, + child: ListView.builder( + itemCount: 15, + itemBuilder: (context, index) { + return YoutubeVideoCard( + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + isImageImportantInCache: false, + video: null, + playlistID: null, + thumbnailWidthPercentage: 0.8, + ); + }, + ), + ) + : LazyLoadListView( + scrollController: uploadsScrollController, + onReachingEnd: () async { + await fetchStreamsNextPage(ch); + }, + listview: (controller) { + return ListView.builder( + padding: EdgeInsets.only(bottom: Dimensions.inst.globalBottomPaddingTotal), + controller: controller, + itemExtent: thumbnailItemExtent, + itemCount: streamsList.length, + itemBuilder: (context, index) { + final item = streamsList[index]; + return YoutubeVideoCard( + key: Key("${context.hashCode}_${(item).id}"), + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + isImageImportantInCache: false, + video: item, + playlistID: null, + thumbnailWidthPercentage: 0.8, + displayThirdLine: false, + ); + }, + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/youtube/pages/yt_channels_page.dart b/lib/youtube/pages/yt_channels_page.dart index 9520e39f..02156100 100644 --- a/lib/youtube/pages/yt_channels_page.dart +++ b/lib/youtube/pages/yt_channels_page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; -import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/core/dimensions.dart'; import 'package:namida/core/extensions.dart'; @@ -12,18 +11,14 @@ import 'package:namida/core/translations/language.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/class/youtube_subscription.dart'; +import 'package:namida/youtube/controller/youtube_channel_controller.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_import_controller.dart'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; +import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; -enum _VideosSorting { - date, - views, - duration, -} - class YoutubeChannelsPage extends StatefulWidget { const YoutubeChannelsPage({super.key}); @@ -31,25 +26,14 @@ class YoutubeChannelsPage extends StatefulWidget { State createState() => _YoutubeChannelsPageState(); } -class _YoutubeChannelsPageState extends State { +class _YoutubeChannelsPageState extends State with YoutubeChannelController { late final ScrollController _horizontalListController; - late final ScrollController _uploadsScrollController; - - YoutubeSubscription? _channel; - final _streamsList = []; - final _sorting = _VideosSorting.date.obs; - final _sortingByTop = true.obs; - - bool _isLoadingInitialStreams = true; - final _isLoadingMoreUploads = false.obs; - final _lastLoadingMoreWasEmpty = false.obs; final _allChannelsStreamsProgress = 0.0.obs; @override void initState() { _horizontalListController = ScrollController(); - _uploadsScrollController = ScrollController(); YoutubeSubscriptionsController.inst.sortByLastFetched(); final sub = YoutubeSubscriptionsController.inst.subscribedChannels.values.lastOrNull; _updateChannel(sub); @@ -59,25 +43,22 @@ class _YoutubeChannelsPageState extends State { @override void dispose() { _horizontalListController.dispose(); - _uploadsScrollController.dispose(); - _isLoadingMoreUploads.close(); - _lastLoadingMoreWasEmpty.close(); _allChannelsStreamsProgress.close(); - _sorting.close(); super.dispose(); } void _updateChannel(YoutubeSubscription? sub) { - _lastLoadingMoreWasEmpty.value = false; - if (_uploadsScrollController.hasClients) _uploadsScrollController.jumpTo(0); + lastLoadingMoreWasEmpty.value = false; + if (uploadsScrollController.hasClients) uploadsScrollController.jumpTo(0); setState(() { - _isLoadingInitialStreams = true; - _channel = sub; - _streamsList.clear(); + isLoadingInitialStreams = true; + channel = sub; + streamsList.clear(); + streamsPeakDates = null; }); if (sub != null) { - _fetchChannelStreams(sub); + fetchChannelStreams(sub); } else { _fetchAllChannelsStreams(null); } @@ -92,7 +73,7 @@ class _YoutubeChannelsPageState extends State { _allChannelsStreamsProgress.value = i / idsLength; final st = await YoutubeController.inst.getChannelStreams(channelID); printy('p: $i / $idsLength = ${_allChannelsStreamsProgress.value} =>> ${st.length} videos'); - if (_channel != null) { + if (channel != null) { _allChannelsStreamsProgress.value = 0.0; return; } @@ -102,43 +83,14 @@ class _YoutubeChannelsPageState extends State { YoutubeSubscriptionsController.inst.sortByLastFetched(); _allChannelsStreamsProgress.value = 0.0; - _sortStreams(); + sortStreams(streams: streams); setState(() { - _isLoadingInitialStreams = false; - _streamsList.addAll(streams); + isLoadingInitialStreams = false; + streamsList.addAll(streams); }); } - Future _fetchChannelStreams(YoutubeSubscription sub) async { - final st = await YoutubeController.inst.getChannelStreams(sub.channelID); - YoutubeSubscriptionsController.inst.refreshLastFetchedTime(sub.channelID); - setState(() { - _isLoadingInitialStreams = false; - if (sub == _channel) { - _streamsList.addAll(st); - } - }); - } - - Future _fetchStreamsNextPage(YoutubeSubscription? sub) async { - if (_isLoadingMoreUploads.value) return; - if (_lastLoadingMoreWasEmpty.value) return; - - _isLoadingMoreUploads.value = true; - final st = await YoutubeController.inst.getChannelStreamsNextPage(); - _isLoadingMoreUploads.value = false; - if (st.isEmpty) { - _lastLoadingMoreWasEmpty.value = true; - return; - } - if (sub == _channel) { - setState(() { - _streamsList.addAll(st); - }); - } - } - Future _onSubscriptionFileImportTap() async { showSystemToast(message: 'choose a "subscriptions.csv" file from a google takeout'); final files = await FilePicker.platform.pickFiles(allowedExtensions: ['csv', 'CSV'], type: FileType.custom); @@ -153,41 +105,6 @@ class _YoutubeChannelsPageState extends State { } } - void _sortStreams({List? streams, _VideosSorting? sort, bool? sortingByTop}) { - sort ??= _sorting.value; - streams ??= _streamsList; - sortingByTop ??= _sortingByTop.value; - switch (sort) { - case _VideosSorting.date: - sortingByTop ? streams.sortByReverse((e) => e.date ?? DateTime(0)) : streams.sortBy((e) => e.date ?? DateTime(0)); - break; - - case _VideosSorting.views: - sortingByTop ? streams.sortByReverse((e) => e.viewCount ?? 0) : streams.sortBy((e) => e.viewCount ?? 0); - break; - - case _VideosSorting.duration: - sortingByTop ? streams.sortByReverse((e) => e.duration ?? Duration.zero) : streams.sortBy((e) => e.duration ?? Duration.zero); - break; - - default: - null; - } - _sorting.value = sort; - _sortingByTop.value = sortingByTop; - } - - (String, IconData) _sortToTextAndIcon(_VideosSorting sort) { - switch (sort) { - case _VideosSorting.date: - return (lang.DATE, Broken.calendar); - case _VideosSorting.views: - return (lang.VIEWS, Broken.eye); - case _VideosSorting.duration: - return (lang.DURATION, Broken.timer_1); - } - } - static const _thumbSize = 48.0; double get _listBottomPadding => Dimensions.inst.globalBottomPaddingEffective - 6.0; final _listTopPadding = 6.0; @@ -197,64 +114,18 @@ class _YoutubeChannelsPageState extends State { Widget build(BuildContext context) { const horizontalPadding = 6.0; - final thumbnailWidth = context.width * 0.3; + final thumbnailWidth = context.width * 0.28; final thumbnailHeight = thumbnailWidth * 9 / 16; final thumbnailItemExtent = thumbnailHeight + 8.0 * 2; - final ch = _channel; + final ch = channel; return Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: ch == null - ? SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ..._VideosSorting.values.map( - (e) { - final details = _sortToTextAndIcon(e); - final enabled = _sorting.value == e; - return NamidaInkWell( - animationDurationMS: 200, - borderRadius: 6.0, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - margin: const EdgeInsets.symmetric(horizontal: 3.0), - bgColor: enabled ? CurrentColor.inst.color : context.theme.cardColor, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - enabled - ? Obx( - () => StackedIcon( - baseIcon: details.$2, - secondaryIcon: _sortingByTop.value ? Broken.arrow_down_2 : Broken.arrow_up_3, - iconSize: 20.0, - secondaryIconSize: 10.0, - blurRadius: 4.0, - ), - ) - : Icon( - details.$2, - size: 20.0, - ), - const SizedBox(width: 4.0), - Text(details.$1, style: context.textTheme.displayMedium), - ], - ), - onTap: () => setState( - () => _sortStreams(sort: e, sortingByTop: enabled ? !_sortingByTop.value : null), - ), - ); - }, - ), - ], - ), - ), - ) + ? sortWidget : Row( children: [ Expanded( @@ -264,6 +135,9 @@ class _YoutubeChannelsPageState extends State { borderRadius: 24.0, bgColor: context.theme.cardColor, padding: const EdgeInsets.symmetric(vertical: 4.0), + onTap: () { + NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: ch.channelID, sub: ch)); + }, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -314,52 +188,71 @@ class _YoutubeChannelsPageState extends State { ), ), Expanded( - child: NamidaScrollbar( - controller: _uploadsScrollController, - child: _isLoadingInitialStreams - ? ShimmerWrapper( - shimmerEnabled: true, - child: ListView.builder( - itemCount: 15, - itemBuilder: (context, index) { - return const YoutubeVideoCard( - isImageImportantInCache: false, - video: null, - playlistID: null, - thumbnailWidthPercentage: 0.8, - ); - }, + child: YoutubeSubscriptionsController.inst.subscribedChannels.isEmpty + ? Stack( + children: [ + Center( + child: Obx( + () => NamidaInkWellButton( + sizeMultiplier: 2.0, + icon: Broken.add_circle, + text: lang.IMPORT, + enabled: !YoutubeImportController.inst.isImportingSubscriptions.value, + onTap: _onSubscriptionFileImportTap, + ), + ), ), - ) - : LazyLoadListView( - scrollController: _uploadsScrollController, - onReachingEnd: () async { - await _fetchStreamsNextPage(_channel); - }, - listview: (controller) { - return ListView.builder( - controller: controller, - itemExtent: thumbnailItemExtent, - itemCount: _streamsList.length, - itemBuilder: (context, index) { - final item = _streamsList[index]; - return YoutubeVideoCard( - key: Key("${context.hashCode}_${(item).id}"), - thumbnailHeight: thumbnailHeight, - thumbnailWidth: thumbnailWidth, - isImageImportantInCache: false, - video: item, - playlistID: null, - thumbnailWidthPercentage: 0.8, - ); - }, - ); - }, - ), - ), + ], + ) + : NamidaScrollbar( + controller: uploadsScrollController, + child: isLoadingInitialStreams + ? ShimmerWrapper( + shimmerEnabled: true, + child: ListView.builder( + itemCount: 15, + itemBuilder: (context, index) { + return YoutubeVideoCard( + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + isImageImportantInCache: false, + video: null, + playlistID: null, + thumbnailWidthPercentage: 0.8, + ); + }, + ), + ) + : LazyLoadListView( + scrollController: uploadsScrollController, + onReachingEnd: () async { + await fetchStreamsNextPage(channel); + }, + listview: (controller) { + return ListView.builder( + controller: controller, + itemExtent: thumbnailItemExtent, + itemCount: streamsList.length, + itemBuilder: (context, index) { + final item = streamsList[index]; + return YoutubeVideoCard( + key: Key("${context.hashCode}_${(item).id}"), + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + isImageImportantInCache: false, + video: item, + playlistID: null, + thumbnailWidthPercentage: 0.8, + displayThirdLine: false, + ); + }, + ); + }, + ), + ), ), Obx( - () => _isLoadingMoreUploads.value + () => isLoadingMoreUploads.value ? const Padding( padding: EdgeInsets.all(8.0), child: Stack( @@ -372,111 +265,116 @@ class _YoutubeChannelsPageState extends State { : const SizedBox(), ), const NamidaContainerDivider(margin: EdgeInsets.only(left: 8.0, right: 8.0)), - Container( - width: context.width, - height: listHeight, - padding: EdgeInsets.only(bottom: _listBottomPadding, top: _listTopPadding), - decoration: BoxDecoration( - color: Color.alphaBlend(context.theme.scaffoldBackgroundColor.withOpacity(0.4), context.theme.cardTheme.color!), - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.0.multipliedRadius), - ), - ), - child: Obx( - () { - final channelIDS = YoutubeSubscriptionsController.inst.subscribedChannels.keys.toList(); - final totalIDsLength = channelIDS.length; - return Row( - children: [ - NamidaInkWell( - borderRadius: 10.0, - animationDurationMS: 150, - bgColor: _channel == null ? context.theme.colorScheme.secondary.withOpacity(0.15) : null, - width: _thumbSize, - margin: const EdgeInsets.symmetric(horizontal: horizontalPadding), - padding: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), - onTap: () { - _updateChannel(null); - }, - child: Column( - children: [ - const SizedBox(height: 4.0), - Stack( + Obx( + () => AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: context.width, + height: listHeight, + child: Container( + padding: EdgeInsets.only(bottom: _listBottomPadding, top: _listTopPadding), + decoration: BoxDecoration( + color: Color.alphaBlend(context.theme.scaffoldBackgroundColor.withOpacity(0.4), context.theme.cardTheme.color!), + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.0.multipliedRadius), + ), + ), + child: Obx( + () { + final channelIDS = YoutubeSubscriptionsController.inst.subscribedChannels.keys.toList(); + final totalIDsLength = channelIDS.length; + return Row( + children: [ + NamidaInkWell( + borderRadius: 10.0, + animationDurationMS: 150, + bgColor: channel == null ? context.theme.colorScheme.secondary.withOpacity(0.15) : null, + width: _thumbSize, + margin: const EdgeInsets.symmetric(horizontal: horizontalPadding), + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), + onTap: () { + _updateChannel(null); + }, + child: Column( children: [ - CircleAvatar( - radius: _thumbSize / 2, - child: FittedBox( - child: Text("$totalIDsLength"), - ), - ), - Positioned.fill( - child: FittedBox( - child: Obx( - () => CircularProgressIndicator( - value: _allChannelsStreamsProgress.value, - strokeWidth: 2.0, + const SizedBox(height: 4.0), + Stack( + children: [ + CircleAvatar( + radius: _thumbSize / 2, + child: FittedBox( + child: Text("$totalIDsLength"), ), ), - ), - ) + Positioned.fill( + child: FittedBox( + child: Obx( + () => CircularProgressIndicator( + value: _allChannelsStreamsProgress.value, + strokeWidth: 2.0, + ), + ), + ), + ) + ], + ), + const SizedBox(height: 4.0), + Text( + lang.ALL, + style: context.textTheme.displaySmall, + overflow: TextOverflow.ellipsis, + ), ], ), - const SizedBox(height: 4.0), - Text( - lang.ALL, - style: context.textTheme.displaySmall, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Expanded( - child: ListView.builder( - controller: _horizontalListController, - padding: EdgeInsets.only(right: Dimensions.inst.globalBottomPaddingFAB + 12.0), - scrollDirection: Axis.horizontal, - itemCount: totalIDsLength, - itemExtent: _thumbSize + horizontalPadding * 2, - itemBuilder: (context, indexPre) { - final index = totalIDsLength - indexPre - 1; - final key = channelIDS[index]; - final ch = YoutubeSubscriptionsController.inst.subscribedChannels[key]!; - final info = YoutubeController.inst.fetchChannelDetailsFromCacheSync(ch.channelID); - final channelName = info?.name == null || info?.name == '' ? ch.title : info?.name; - return NamidaInkWell( - borderRadius: 10.0, - animationDurationMS: 150, - bgColor: _channel == ch ? context.theme.colorScheme.secondary.withOpacity(0.1) : null, - width: _thumbSize, - padding: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), - margin: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), - onTap: () => _updateChannel(ch), - child: Column( - children: [ - const SizedBox(height: 4.0), - YoutubeThumbnail( - key: Key(ch.channelID), - width: _thumbSize, - isImportantInCache: true, - channelUrl: info?.avatarUrl ?? info?.thumbnailUrl ?? ch.channelID, - channelIDForHQImage: ch.channelID, - isCircle: true, + ), + Expanded( + child: ListView.builder( + controller: _horizontalListController, + padding: EdgeInsets.only(right: Dimensions.inst.globalBottomPaddingFAB + 12.0), + scrollDirection: Axis.horizontal, + itemCount: totalIDsLength, + itemExtent: _thumbSize + horizontalPadding * 2, + itemBuilder: (context, indexPre) { + final index = totalIDsLength - indexPre - 1; + final key = channelIDS[index]; + final ch = YoutubeSubscriptionsController.inst.subscribedChannels[key]!; + final info = YoutubeController.inst.fetchChannelDetailsFromCacheSync(ch.channelID); + final channelName = info?.name == null || info?.name == '' ? ch.title : info?.name; + return NamidaInkWell( + borderRadius: 10.0, + animationDurationMS: 150, + bgColor: channel?.channelID == ch.channelID ? context.theme.colorScheme.secondary.withOpacity(0.1) : null, + width: _thumbSize, + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), + margin: const EdgeInsets.symmetric(horizontal: horizontalPadding / 2), + onTap: () => _updateChannel(ch), + child: Column( + children: [ + const SizedBox(height: 4.0), + YoutubeThumbnail( + key: Key(ch.channelID), + width: _thumbSize, + isImportantInCache: true, + channelUrl: info?.avatarUrl ?? info?.thumbnailUrl ?? ch.channelID, + channelIDForHQImage: ch.channelID, + isCircle: true, + ), + const SizedBox(height: 4.0), + Text( + channelName ?? '', + style: context.textTheme.displaySmall, + overflow: TextOverflow.ellipsis, + ) + ], ), - const SizedBox(height: 4.0), - Text( - channelName ?? '', - style: context.textTheme.displaySmall, - overflow: TextOverflow.ellipsis, - ) - ], - ), - ); - }, - ), - ), - ], - ); - }, + ); + }, + ), + ), + ], + ); + }, + ), + ), ), ), ], diff --git a/lib/youtube/widgets/yt_card.dart b/lib/youtube/widgets/yt_card.dart index ea9b7f37..be3b438f 100644 --- a/lib/youtube/widgets/yt_card.dart +++ b/lib/youtube/widgets/yt_card.dart @@ -166,11 +166,11 @@ class YoutubeCard extends StatelessWidget { const SizedBox(width: 6.0), ], if (displaythirdLineText) - Expanded( - child: NamidaDummyContainer( - width: context.width * 0.35, - height: 8.0, - shimmerEnabled: shimmerEnabled && (thirdLineText == '' || !displaythirdLineText), + NamidaDummyContainer( + width: context.width * 0.2, + height: 8.0, + shimmerEnabled: shimmerEnabled && (thirdLineText == '' || !displaythirdLineText), + child: Expanded( child: Container( alignment: Alignment.centerLeft, width: double.infinity, diff --git a/lib/youtube/widgets/yt_channel_card.dart b/lib/youtube/widgets/yt_channel_card.dart index 2bcc3729..7281cdc0 100644 --- a/lib/youtube/widgets/yt_channel_card.dart +++ b/lib/youtube/widgets/yt_channel_card.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; @@ -26,12 +28,16 @@ class _YoutubeChannelCardState extends State { final thumbnailSize = widget.thumbnailSize ?? context.width * 0.2; const verticalPadding = 8.0; final shimmerEnabled = channel == null; + final avatarUrl = channel?.avatarUrl ?? channel?.thumbnailUrl; return NamidaInkWell( margin: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), bgColor: bgColor?.withAlpha(100) ?? context.theme.cardColor, animationDurationMS: 300, borderRadius: 24.0, - onTap: () {}, + onTap: () { + final chid = channel?.id; + if (chid != null) NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid, channel: channel)); + }, height: thumbnailSize + verticalPadding, child: Row( children: [ @@ -41,10 +47,10 @@ class _YoutubeChannelCardState extends State { height: thumbnailSize, shimmerEnabled: shimmerEnabled, child: YoutubeThumbnail( - key: Key("${channel?.avatarUrl}_${channel?.id}"), + key: Key("${avatarUrl}_${channel?.id}"), compressed: false, isImportantInCache: true, - channelUrl: channel?.avatarUrl, + channelUrl: avatarUrl, channelIDForHQImage: channel?.id ?? '', width: thumbnailSize, height: thumbnailSize, diff --git a/lib/youtube/widgets/yt_subscribe_buttons.dart b/lib/youtube/widgets/yt_subscribe_buttons.dart new file mode 100644 index 00000000..f85a5a8b --- /dev/null +++ b/lib/youtube/widgets/yt_subscribe_buttons.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:namida/core/icon_fonts/broken_icons.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; + +class YTSubscribeButton extends StatelessWidget { + final String? channelIDOrURL; + const YTSubscribeButton({super.key, required this.channelIDOrURL}); + + @override + Widget build(BuildContext context) { + return Obx( + () { + final channelID = YoutubeSubscriptionsController.inst.idOrUrlToChannelID(channelIDOrURL); + final disabled = channelID == null; + final subscribed = YoutubeSubscriptionsController.inst.subscribedChannels[channelID ?? '']?.subscribed ?? false; + return AnimatedOpacity( + opacity: disabled ? 0.5 : 1.0, + duration: const Duration(milliseconds: 300), + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Color.alphaBlend(Colors.grey.withOpacity(subscribed ? 0.6 : 0.0), context.theme.colorScheme.primary), + ), + child: Row( + children: [ + Icon(subscribed ? Broken.tick_square : Broken.video, size: 20.0), + const SizedBox(width: 8.0), + Text( + subscribed ? lang.SUBSCRIBED : lang.SUBSCRIBE, + ), + ], + ), + onPressed: () async { + if (channelIDOrURL != null) { + await YoutubeSubscriptionsController.inst.changeChannelStatus(channelIDOrURL!, null); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/youtube/widgets/yt_thumbnail.dart b/lib/youtube/widgets/yt_thumbnail.dart index dcfb0ef1..b805d08c 100644 --- a/lib/youtube/widgets/yt_thumbnail.dart +++ b/lib/youtube/widgets/yt_thumbnail.dart @@ -159,7 +159,7 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe } } - Key get thumbKey => Key("$smallBoxDynamicColor${widget.videoId}${widget.channelUrl}${imageBytes?.length}$imagePath${widget.smallBoxText}"); + Key get thumbKey => Key("$smallBoxDynamicColor${widget.videoId}${widget.channelUrl}${widget.channelIDForHQImage}${imageBytes?.length}$imagePath${widget.smallBoxText}"); @override Widget build(BuildContext context) { @@ -167,6 +167,12 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe padding: widget.margin ?? EdgeInsets.zero, child: ArtworkWidget( key: thumbKey, + onError: () { + imagePath = null; + imageColors = null; + smallBoxDynamicColor = null; + _getThumbnail(); + }, isCircle: widget.isCircle, bgcolor: context.theme.cardColor.withAlpha(60), compressed: widget.compressed, diff --git a/lib/youtube/widgets/yt_video_card.dart b/lib/youtube/widgets/yt_video_card.dart index 06178227..26dd6b4b 100644 --- a/lib/youtube/widgets/yt_video_card.dart +++ b/lib/youtube/widgets/yt_video_card.dart @@ -23,6 +23,7 @@ class YoutubeVideoCard extends StatelessWidget { final int? index; final double fontMultiplier; final double thumbnailWidthPercentage; + final bool displayThirdLine; const YoutubeVideoCard({ super.key, @@ -36,6 +37,7 @@ class YoutubeVideoCard extends StatelessWidget { this.index, this.fontMultiplier = 1.0, this.thumbnailWidthPercentage = 1.0, + this.displayThirdLine = true, }); @override @@ -67,7 +69,10 @@ class YoutubeVideoCard extends StatelessWidget { if (videoViewCount != null && videoViewCount >= 0) "${videoViewCount.formatDecimalShort()} ${videoViewCount == 0 ? lang.VIEW : lang.VIEWS}", if (video?.textualUploadDate != null) video?.textualUploadDate, ].join(' - '), + displaythirdLineText: displayThirdLine, thirdLineText: video?.uploaderName ?? '', + displayChannelThumbnail: displayThirdLine, + channelThumbnailUrl: video?.uploaderAvatarUrl, onTap: onTap ?? () async { if (idNull != null) { @@ -99,8 +104,6 @@ class YoutubeVideoCard extends StatelessWidget { YTUtils.expandMiniplayer(); } }, - channelThumbnailUrl: video?.uploaderAvatarUrl, - displayChannelThumbnail: true, smallBoxText: video?.duration?.inSeconds.secondsLabel, bottomRightWidgets: idNull == null ? [] : YTUtils.getVideoCacheStatusIcons(videoId: idNull, context: context), menuChildrenDefault: menuItems, diff --git a/lib/youtube/widgets/yt_videos_actions_bar.dart b/lib/youtube/widgets/yt_videos_actions_bar.dart new file mode 100644 index 00000000..94f33715 --- /dev/null +++ b/lib/youtube/widgets/yt_videos_actions_bar.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:newpipeextractor_dart/models/stream_info_item.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/player_controller.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/namida_converter_ext.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; +import 'package:namida/youtube/pages/yt_playlist_download_subpage.dart'; + +class YTVideosActionBarOptions { + final bool shuffle; + final bool play; + final bool playNext; + final bool playLast; + final bool download; + final bool addToPlaylist; + + const YTVideosActionBarOptions({ + this.shuffle = true, + this.play = false, + this.playNext = false, + this.playLast = true, + this.download = true, + this.addToPlaylist = false, + }); +} + +class YTVideosActionBar extends StatelessWidget { + final String title; + final String url; + final List Function() videosCallback; + final Map Function()? infoLookupCallback; + final Color? colorScheme; + final YTVideosActionBarOptions barOptions; + final YTVideosActionBarOptions menuOptions; + + const YTVideosActionBar({ + super.key, + required this.title, + required this.url, + required this.videosCallback, + this.infoLookupCallback, + this.colorScheme, + this.barOptions = const YTVideosActionBarOptions(), + this.menuOptions = const YTVideosActionBarOptions( + shuffle: false, + play: true, + playNext: true, + playLast: true, + download: false, + addToPlaylist: true, + ), + }); + + List get videos => videosCallback(); + Map get infoLookup => infoLookupCallback?.call() ?? {}; + + Future _onDownloadTap() async { + await NamidaNavigator.inst.navigateTo( + YTPlaylistDownloadPage( + ids: videos, + playlistName: title, + infoLookup: infoLookup, + ), + ); + } + + Future _onShuffle() async { + await Player.inst.playOrPause(0, videos, QueueSource.others, shuffle: true); + } + + Future _onPlay() async { + await Player.inst.playOrPause(0, videos, QueueSource.others); + } + + Future _onPlayNext() async { + await Player.inst.addToQueue(videos, insertNext: true); + } + + Future _onPlayLast() async { + await Player.inst.addToQueue(videos, insertNext: false); + } + + void _onAddToPlaylist() { + final ids = []; + final info = {}; + videos.loop((e, index) { + final id = e.id; + ids.add(id); + info[id] = infoLookup[id]?.name; + }); + + showAddToPlaylistSheet( + ids: ids, + idsNamesLookup: info, + ); + } + + @override + Widget build(BuildContext context) { + final countText = videos.length; + final menuItems = [ + if (menuOptions.addToPlaylist) + NamidaPopupItem( + icon: Broken.music_playlist, + title: lang.ADD_TO_PLAYLIST, + onTap: _onAddToPlaylist, + ), + if (url != '') + NamidaPopupItem( + icon: Broken.share, + title: lang.SHARE, + onTap: () => Share.share(url), + ), + if (menuOptions.download) + NamidaPopupItem( + icon: Broken.import, + title: lang.DOWNLOAD, + onTap: _onDownloadTap, + ), + if (menuOptions.shuffle) + NamidaPopupItem( + icon: Broken.shuffle, + title: "${lang.SHUFFLE} ($countText)", + onTap: _onShuffle, + ), + if (menuOptions.play) + NamidaPopupItem( + icon: Broken.play, + title: "${lang.PLAY} ($countText)", + onTap: _onPlay, + ), + if (menuOptions.playNext) + NamidaPopupItem( + icon: Broken.next, + title: "${lang.PLAY_NEXT} ($countText)", + onTap: _onPlayNext, + ), + if (menuOptions.playLast) + NamidaPopupItem( + icon: Broken.play_cricle, + title: "${lang.PLAY_LAST} ($countText)", + onTap: _onPlayLast, + ), + ]; + + return Row( + children: [ + if (barOptions.addToPlaylist) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.music_playlist, + tooltip: lang.ADD_TO_PLAYLIST, + onPressed: _onAddToPlaylist, + ), + if (barOptions.download) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.import, + tooltip: lang.DOWNLOAD, + onPressed: _onDownloadTap, + ), + if (barOptions.shuffle) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.shuffle, + tooltip: lang.SHUFFLE, + onPressed: _onShuffle, + ), + if (barOptions.play) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.play, + tooltip: lang.PLAY, + onPressed: _onPlay, + ), + if (barOptions.playNext) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.next, + tooltip: lang.PLAY_NEXT, + onPressed: _onPlayNext, + ), + if (barOptions.playLast) + NamidaIconButton( + iconSize: 22.0, + horizontalPadding: 6.0, + iconColor: context.defaultIconColor(colorScheme), + icon: Broken.play_cricle, + tooltip: lang.PLAY_LAST, + onPressed: _onPlayLast, + ), + if (menuItems.isNotEmpty) + NamidaPopupWrapper( + openOnLongPress: false, + childrenDefault: menuItems, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Icon( + Broken.more_2, + color: context.defaultIconColor(colorScheme), + ), + ), + ), + ], + ); + } +} diff --git a/lib/youtube/youtube_miniplayer.dart b/lib/youtube/youtube_miniplayer.dart index 625c166d..7c880893 100644 --- a/lib/youtube/youtube_miniplayer.dart +++ b/lib/youtube/youtube_miniplayer.dart @@ -19,22 +19,23 @@ 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/packages/mp.dart'; -import 'package:namida/packages/three_arched_circle.dart'; import 'package:namida/packages/scroll_physics_modified.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/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart' hide YoutubePlaylist; -import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; import 'package:namida/youtube/functions/download_sheet.dart'; import 'package:namida/youtube/functions/video_listens_dialog.dart'; +import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/widgets/yt_action_button.dart'; import 'package:namida/youtube/widgets/yt_channel_card.dart'; import 'package:namida/youtube/widgets/yt_comment_card.dart'; import 'package:namida/youtube/widgets/yt_playlist_card.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; +import 'package:namida/youtube/widgets/yt_subscribe_buttons.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; import 'package:namida/youtube/yt_miniplayer_comments_subpage.dart'; @@ -401,115 +402,95 @@ class YoutubeMiniPlayer extends StatelessWidget { shimmerDurationMS: 550, shimmerDelayMS: 250, shimmerEnabled: channelName == null || channelThumbnail == null || channelSubs == null, - child: Row( - children: [ - const SizedBox(width: 18.0), - NamidaDummyContainer( - width: 42.0, - height: 42.0, - borderRadius: 100.0, - shimmerEnabled: channelThumbnail == null, - child: YoutubeThumbnail( - key: Key(channelThumbnail ?? ''), - isImportantInCache: true, - channelUrl: channelThumbnail ?? '', - channelIDForHQImage: channelIDOrURL ?? '', - width: 42.0, - height: 42.0, - isCircle: true, - ), - ), - const SizedBox(width: 8.0), - Expanded( - // key: Key(currentId), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - child: Row( - children: [ - NamidaDummyContainer( - width: 114.0, - height: 12.0, + child: Material( + child: InkWell( + onTap: () { + final channel = videoChannel ?? Player.inst.currentChannelInfo; + final chid = channel?.id; + if (chid != null) NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid, channel: channel)); + }, + child: Row( + children: [ + const SizedBox(width: 18.0), + NamidaDummyContainer( + width: 42.0, + height: 42.0, + borderRadius: 100.0, + shimmerEnabled: channelThumbnail == null && (channelIDOrURL == null || channelIDOrURL == ''), + child: YoutubeThumbnail( + key: Key("${channelThumbnail}_$channelIDOrURL"), + isImportantInCache: true, + channelUrl: channelThumbnail ?? '', + channelIDForHQImage: channelIDOrURL ?? '', + width: 42.0, + height: 42.0, + isCircle: true, + ), + ), + const SizedBox(width: 8.0), + Expanded( + // key: Key(currentId), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + child: Row( + children: [ + NamidaDummyContainer( + width: 114.0, + height: 12.0, + borderRadius: 4.0, + shimmerEnabled: channelName == null, + child: Text( + channelName ?? '', + style: context.textTheme.displayMedium?.copyWith( + fontSize: 13.5.multipliedFontScale, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ), + ), + if (channelIsVerified) ...[ + const SizedBox(width: 4.0), + const Icon( + Broken.shield_tick, + size: 12.0, + ), + ] + ], + ), + ), + const SizedBox(height: 2.0), + FittedBox( + child: NamidaDummyContainer( + width: 92.0, + height: 10.0, borderRadius: 4.0, - shimmerEnabled: channelName == null, + shimmerEnabled: channelSubs == null, child: Text( - channelName ?? '', - style: context.textTheme.displayMedium?.copyWith( - fontSize: 13.5.multipliedFontScale, + [ + channelSubs?.formatDecimalShort(isTitleExpanded.value) ?? '?', + (channelSubs ?? 0) < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS + ].join(' '), + style: context.textTheme.displaySmall?.copyWith( + fontSize: 12.0.multipliedFontScale, ), maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, ), ), - if (channelIsVerified) ...[ - const SizedBox(width: 4.0), - const Icon( - Broken.shield_tick, - size: 12.0, - ), - ] - ], - ), - ), - const SizedBox(height: 2.0), - FittedBox( - child: NamidaDummyContainer( - width: 92.0, - height: 10.0, - borderRadius: 4.0, - shimmerEnabled: channelSubs == null, - child: Text( - [ - channelSubs?.formatDecimalShort(isTitleExpanded.value) ?? '?', - (channelSubs ?? 0) < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS - ].join(' '), - style: context.textTheme.displaySmall?.copyWith( - fontSize: 12.0.multipliedFontScale, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - ), - ], - ), - ), - const SizedBox(width: 12.0), - Obx( - () { - final channelID = YoutubeSubscriptionsController.inst.idOrUrlToChannelID(channelIDOrURL); - final disabled = channelID == null; - final subscribed = YoutubeSubscriptionsController.inst.subscribedChannels[channelID ?? '']?.subscribed ?? false; - return AnimatedOpacity( - opacity: disabled ? 0.5 : 1.0, - duration: const Duration(milliseconds: 300), - child: TextButton( - style: TextButton.styleFrom( - foregroundColor: Color.alphaBlend(Colors.grey.withOpacity(subscribed ? 0.6 : 0.0), context.theme.colorScheme.primary), - ), - child: Row( - children: [ - Icon(subscribed ? Broken.tick_square : Broken.video, size: 20.0), - const SizedBox(width: 8.0), - Text( - subscribed ? lang.SUBSCRIBED : lang.SUBSCRIBE, - ), - ], - ), - onPressed: () async { - if (channelIDOrURL != null) { - await YoutubeSubscriptionsController.inst.changeChannelStatus(channelIDOrURL, null); - } - }, + ], ), - ); - }, + ), + const SizedBox(width: 12.0), + YTSubscribeButton(channelIDOrURL: channelIDOrURL), + const SizedBox(width: 20.0), + ], ), - const SizedBox(width: 20.0), - ], + ), ), ), ),