diff --git a/lib/base/pull_to_refresh.dart b/lib/base/pull_to_refresh.dart index 17c60c58..3cbaa343 100644 --- a/lib/base/pull_to_refresh.dart +++ b/lib/base/pull_to_refresh.dart @@ -6,11 +6,43 @@ import 'package:namida/core/utils.dart'; typedef PullToRefreshCallback = Future Function(); const double _defaultMaxDistance = 128.0; +class PullToRefreshWidget extends StatelessWidget { + final Widget child; + final ScrollController controller; + final PullToRefreshCallback onRefresh; + final PullToRefreshMixin state; + + const PullToRefreshWidget({ + super.key, + required this.child, + required this.controller, + required this.onRefresh, + required this.state, + }); + + @override + Widget build(BuildContext context) { + return Listener( + onPointerMove: (event) => state.onPointerMove(controller, event), + onPointerUp: (_) => state.onRefresh(onRefresh), + onPointerCancel: (_) => state.onVerticalDragFinish(), + child: Stack( + alignment: Alignment.topCenter, + children: [ + child, + state.pullToRefreshWidget, + ], + ), + ); + } +} + class PullToRefresh extends StatefulWidget { final Widget child; final ScrollController controller; final PullToRefreshCallback onRefresh; final double maxDistance; + final bool Function()? enablePullToRefresh; const PullToRefresh({ super.key, @@ -18,6 +50,7 @@ class PullToRefresh extends StatefulWidget { required this.controller, required this.onRefresh, this.maxDistance = _defaultMaxDistance, + this.enablePullToRefresh, }); @override @@ -28,19 +61,16 @@ class _PullToRefreshState extends State with TickerProviderStateM @override double get maxDistance => widget.maxDistance; + @override + bool get enablePullToRefresh => widget.enablePullToRefresh == null ? true : widget.enablePullToRefresh!(); + @override Widget build(BuildContext context) { - return Listener( - onPointerMove: (event) => onPointerMove(widget.controller, event), - onPointerUp: (_) => onRefresh(widget.onRefresh), - onPointerCancel: (_) => onVerticalDragFinish(), - child: Stack( - alignment: Alignment.topCenter, - children: [ - widget.child, - pullToRefreshWidget, - ], - ), + return PullToRefreshWidget( + state: this, + controller: widget.controller, + onRefresh: widget.onRefresh, + child: widget.child, ); } } diff --git a/lib/base/youtube_streams_manager.dart b/lib/base/youtube_streams_manager.dart index de808de1..296ea68f 100644 --- a/lib/base/youtube_streams_manager.dart +++ b/lib/base/youtube_streams_manager.dart @@ -151,15 +151,16 @@ mixin YoutubeStreamsManager> { } } - Future fetchStreamsNextPage() async { - if (isLoadingMoreUploads.value) return; + Future fetchStreamsNextPage() async { + bool didFetch = false; + if (isLoadingMoreUploads.value) return didFetch; final result = this.listWrapper; - if (result == null) return; - if (!result.canFetchNext) return; + if (result == null) return didFetch; + if (!result.canFetchNext) return didFetch; isLoadingMoreUploads.value = true; - final didFetch = await result.fetchNext(); + didFetch = await result.fetchNext(); isLoadingMoreUploads.value = false; if (didFetch) { @@ -167,6 +168,7 @@ mixin YoutubeStreamsManager> { onListChange(trySortStreams); // refresh state even if will not sort } } + return didFetch; } Future fetchAllStreams(void Function(YoutiPieFetchAllRes fetchAllRes) controller) async { diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 9449215d..5b319876 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -225,6 +225,8 @@ enum RouteType { YOUTUBE_USER_MANAGE_ACCOUNT_SUBPAGE, YOUTUBE_USER_MANAGE_SUBSCRIPTION_SUBPAGE, + YOUTUBE_HISTORY_HOSTED_SUBPAGE, + /// others UNKNOWN, } diff --git a/lib/ui/widgets/custom_widgets.dart b/lib/ui/widgets/custom_widgets.dart index 5e95131d..99f322f7 100644 --- a/lib/ui/widgets/custom_widgets.dart +++ b/lib/ui/widgets/custom_widgets.dart @@ -3152,16 +3152,25 @@ class LazyLoadListView extends StatefulWidget { class _LazyLoadListViewState extends State { late final ScrollController controller; - bool isExecuting = false; + bool _isExecuting = false; - void _scrollListener() async { - if (isExecuting) return; + bool? _lastWasSuccess; + bool _isInExtendRange = false; // prevent re-execution if latest failed & still in range. - if (controller.offset >= controller.position.maxScrollExtent - widget.extend && !controller.position.outOfRange) { - if (widget.requiresNetwork && !ConnectivityController.inst.hasConnection) return; - isExecuting = true; - await widget.onReachingEnd(); - isExecuting = false; + void _scrollListener() async { + if (_isExecuting) return; + + if (controller.offset >= controller.position.maxScrollExtent - widget.extend) { + if (!controller.position.outOfRange) { + if (_lastWasSuccess == false && _isInExtendRange) return; + _isInExtendRange = true; + if (widget.requiresNetwork && !ConnectivityController.inst.hasConnection) return; + _isExecuting = true; + _lastWasSuccess = await widget.onReachingEnd(); + _isExecuting = false; + } + } else { + _isInExtendRange = false; } } diff --git a/lib/youtube/pages/youtube_home_view.dart b/lib/youtube/pages/youtube_home_view.dart index e290935f..55122c57 100644 --- a/lib/youtube/pages/youtube_home_view.dart +++ b/lib/youtube/pages/youtube_home_view.dart @@ -46,7 +46,7 @@ class YouTubeHomeView extends StatelessWidget with NamidaRouteWidget { YoutubeNotificationsPage(), YoutubeChannelsPage(), YoutubePlaylistsView(), - YoutubePlaylistsPage(), + YoutubeUserPlaylistsPage(), YTDownloadsPage(), ], ), diff --git a/lib/youtube/pages/youtube_main_page_fetcher_acc_base.dart b/lib/youtube/pages/youtube_main_page_fetcher_acc_base.dart index c3de1fdd..9cf4454e 100644 --- a/lib/youtube/pages/youtube_main_page_fetcher_acc_base.dart +++ b/lib/youtube/pages/youtube_main_page_fetcher_acc_base.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:youtipie/class/cache_details.dart'; import 'package:youtipie/class/execute_details.dart'; import 'package:youtipie/class/map_serializable.dart'; @@ -19,6 +22,8 @@ import 'package:namida/youtube/pages/user/youtube_account_manage_page.dart'; typedef YoutubeMainPageFetcherItemBuilder = Widget? Function(T item, int index, W list); +final _resultsFetchTime = {}; + class YoutubeMainPageFetcherAccBase, T extends MapSerializable> extends StatefulWidget { final bool transparentShimmer; final String title; @@ -27,7 +32,16 @@ class YoutubeMainPageFetcherAccBase, T extends final Widget dummyCard; final double itemExtent; final YoutubeMainPageFetcherItemBuilder itemBuilder; - final SliverMultiBoxAdaptorWidget? Function(W list, YoutubeMainPageFetcherItemBuilder itemBuilder, Widget dummyCard)? sliverListBuilder; + final RenderObjectWidget? Function(W list, YoutubeMainPageFetcherItemBuilder itemBuilder, Widget dummyCard)? sliverListBuilder; + + final Widget? pageHeader; + final void Function()? onHeaderTap; + final bool isHorizontal; + final double? horizontalHeight; + final double topPadding; + final Future Function()? onPullToRefresh; + final bool enablePullToRefresh; + final void Function(W? result)? onListUpdated; const YoutubeMainPageFetcherAccBase({ super.key, @@ -39,13 +53,38 @@ class YoutubeMainPageFetcherAccBase, T extends required this.itemExtent, required this.itemBuilder, this.sliverListBuilder, + this.pageHeader, + this.onHeaderTap, + this.isHorizontal = false, + this.horizontalHeight, + this.topPadding = 24.0, + this.onPullToRefresh, + this.enablePullToRefresh = true, + this.onListUpdated, }); @override State createState() => _YoutubePageState(); } -class _YoutubePageState, T extends MapSerializable> extends State> { +class _YoutubePageState, T extends MapSerializable> extends State> + with TickerProviderStateMixin, PullToRefreshMixin { + @override + bool get enablePullToRefresh => widget.enablePullToRefresh; + + @override + double get maxDistance => 64.0; + + Future forceFetchFeed() => _fetchFeed(); + void updateList(W? list) { + _currentFeed.value = list; + _lastFetchWasCached.value = false; + } + + void _onListUpdated() { + widget.onListUpdated!(_currentFeed.value); + } + final _controller = ScrollController(); final _isLoadingCurrentFeed = false.obs; final _isLoadingNext = false.obs; @@ -65,17 +104,38 @@ class _YoutubePageState, T extends MapSerializa @override void initState() { super.initState(); + + bool needNewRequest = false; + final lastFetchedTime = _resultsFetchTime[W]; + if (_hasConnection) { + if (lastFetchedTime == null) { + needNewRequest = true; + } else if (lastFetchedTime.difference(DateTime.now()).abs() > const Duration(seconds: 180)) { + needNewRequest = true; + } + } + final cachedFeed = widget.cacheReader.read(); if (cachedFeed != null) { _currentFeed.value = cachedFeed; _lastFetchWasCached.value = true; + if (needNewRequest) { + if (widget.enablePullToRefresh) { + onRefresh(_fetchFeedSilent, forceProceed: true); + } else { + _fetchFeedSilent(); + } + } } else { _fetchFeed(); } + if (widget.onListUpdated != null) _currentFeed.addListener(_onListUpdated); } @override void dispose() { + if (widget.onListUpdated != null) _currentFeed.removeListener(_onListUpdated); + _controller.dispose(); _isLoadingCurrentFeed.close(); _currentFeed.close(); @@ -89,6 +149,7 @@ class _YoutubePageState, T extends MapSerializa _lastFetchWasCached.value = false; _isLoadingCurrentFeed.value = true; final val = await widget.networkFetcher(ExecuteDetails.forceRequest()); + _resultsFetchTime[W] = DateTime.now(); _isLoadingCurrentFeed.value = false; if (val != null) { _currentFeed.value = val; @@ -97,49 +158,97 @@ class _YoutubePageState, T extends MapSerializa } } - Future _fetchFeedNext() async { + Future _fetchFeedSilent() async { + if (!_hasConnection) return _showNetworkError(); + + final val = await widget.networkFetcher(ExecuteDetails.forceRequest()); + _resultsFetchTime[W] = DateTime.now(); + if (val != null) { + _currentFeed.value = val; + } else { + _lastFetchWasCached.value = true; + } + } + + Future _fetchFeedNext() async { + bool fetched = false; final feed = _currentFeed; - if (feed.value?.canFetchNext != true) return; + if (feed.value?.canFetchNext != true) return fetched; _isLoadingNext.value = true; - final fetched = await feed.value?.fetchNext(); + fetched = await feed.value?.fetchNext() ?? false; if (fetched == true) feed.refresh(); _isLoadingNext.value = false; + return fetched; } @override Widget build(BuildContext context) { - final header = Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Text( - widget.title, - style: context.textTheme.displayLarge?.copyWith(fontSize: 38.0), - ), - ), - const SizedBox(width: 12.0), - ObxO( - rx: _lastFetchWasCached, - builder: (value) => value - ? NamidaIconButton( - icon: Broken.refresh, - onPressed: _fetchFeed, - ) - : const SizedBox(), + Widget header = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + widget.title, + style: context.textTheme.displayLarge?.copyWith(fontSize: 28.0), ), - ], - ), + ), + const SizedBox(width: 12.0), + ObxO( + rx: _lastFetchWasCached, + builder: (value) => value + ? NamidaIconButton( + icon: Broken.refresh, + onPressed: _fetchFeed, + ) + : const SizedBox(), + ), + if (widget.onHeaderTap != null) const SizedBox(width: 12.0), + if (widget.onHeaderTap != null) const Icon(Broken.arrow_right_3), + ], ); + const headerMaxHorizontalPadding = 24.0; + const headerMaxVerticalPadding = 16.0; - final pagePadding = EdgeInsets.only(top: 24.0, bottom: Dimensions.inst.globalBottomPaddingTotalR); + if (widget.onHeaderTap != null) { + header = Padding( + padding: const EdgeInsets.symmetric(horizontal: headerMaxHorizontalPadding / 2, vertical: headerMaxVerticalPadding / 2), + child: header, + ); + header = NamidaInkWell( + onTap: widget.onHeaderTap, + margin: const EdgeInsets.symmetric(horizontal: headerMaxHorizontalPadding / 2, vertical: headerMaxVerticalPadding / 2), + child: header, + ); + } else { + header = Padding( + padding: const EdgeInsets.symmetric(horizontal: headerMaxHorizontalPadding, vertical: headerMaxVerticalPadding), + child: header, + ); + } + + final pagePadding = EdgeInsets.only(top: widget.topPadding, bottom: Dimensions.inst.globalBottomPaddingTotalR); + + final EdgeInsets firstPadding; + final EdgeInsets lastPadding; + if (widget.isHorizontal) { + firstPadding = const EdgeInsets.only(left: 12.0); + lastPadding = const EdgeInsets.only(right: 12.0); + } else { + firstPadding = EdgeInsets.only(top: pagePadding.top); + lastPadding = EdgeInsets.only(bottom: pagePadding.bottom); + } return BackgroundWrapper( - child: PullToRefresh( + child: PullToRefreshWidget( + state: this, controller: _controller, - onRefresh: _fetchFeed, + onRefresh: widget.onPullToRefresh == null + ? _fetchFeedSilent + : () => Future.wait([ + _fetchFeedSilent(), + widget.onPullToRefresh!(), + ]), child: ObxO( rx: YoutubeAccountController.current.activeAccountChannel, builder: (activeAccountChannel) => activeAccountChannel == null @@ -174,56 +283,78 @@ class _YoutubePageState, T extends MapSerializa return LazyLoadListView( onReachingEnd: _fetchFeedNext, scrollController: _controller, - listview: (controller) => CustomScrollView( - controller: controller, - slivers: [ - SliverPadding(padding: EdgeInsets.only(top: pagePadding.top)), - SliverToBoxAdapter( - child: header, - ), - isLoadingCurrentFeed - ? SliverToBoxAdapter( - child: ShimmerWrapper( - transparent: widget.transparentShimmer, - shimmerEnabled: true, - child: ListView.builder( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemCount: 15, - shrinkWrap: true, - itemBuilder: (_, __) { - return widget.dummyCard; - }, - ), - ), - ) - : listItems == null - ? const SliverToBoxAdapter() - : widget.sliverListBuilder?.call(listItems, widget.itemBuilder, widget.dummyCard) ?? - SliverFixedExtentList.builder( - itemCount: listItems.items.length, - itemExtent: widget.itemExtent, - itemBuilder: (context, i) { - final item = listItems.items[i]; - return widget.itemBuilder(item, i, listItems); + listview: (controller) { + final customScrollView = CustomScrollView( + scrollDirection: widget.isHorizontal ? Axis.horizontal : Axis.vertical, + controller: controller, + slivers: [ + if (!widget.isHorizontal && widget.pageHeader != null) + SliverToBoxAdapter( + child: widget.pageHeader, + ), + SliverPadding(padding: firstPadding), + if (!widget.isHorizontal) + SliverToBoxAdapter( + child: header, + ), + isLoadingCurrentFeed + ? SliverToBoxAdapter( + child: ShimmerWrapper( + transparent: widget.transparentShimmer, + shimmerEnabled: true, + child: ListView.builder( + scrollDirection: widget.isHorizontal ? Axis.horizontal : Axis.vertical, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: 15, + shrinkWrap: true, + itemBuilder: (_, __) { + return widget.dummyCard; }, ), - SliverToBoxAdapter( - child: ObxO( - rx: _isLoadingNext, - builder: (isLoadingNext) => isLoadingNext - ? const Padding( - padding: EdgeInsets.all(12.0), - child: Center( - child: LoadingIndicator(), - ), - ) - : const SizedBox(), + ), + ) + : listItems == null + ? const SliverToBoxAdapter() + : widget.sliverListBuilder?.call(listItems, widget.itemBuilder, widget.dummyCard) ?? + SliverFixedExtentList.builder( + itemCount: listItems.items.length, + itemExtent: widget.itemExtent, + itemBuilder: (context, i) { + final item = listItems.items[i]; + return widget.itemBuilder(item, i, listItems); + }, + ), + 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)), - ], - ), + SliverPadding(padding: lastPadding), + ], + ); + return widget.isHorizontal + ? Column( + children: [ + if (widget.pageHeader != null) widget.pageHeader!, + SizedBox(height: widget.topPadding), + header, + SizedBox( + height: widget.horizontalHeight, + child: customScrollView, + ), + ], + ) + : customScrollView; + }, ); }, ), diff --git a/lib/youtube/pages/youtube_user_history_page.dart b/lib/youtube/pages/youtube_user_history_page.dart new file mode 100644 index 00000000..4b78ff16 --- /dev/null +++ b/lib/youtube/pages/youtube_user_history_page.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:youtipie/class/chunks/history_chunk.dart'; +import 'package:youtipie/class/publish_time.dart'; +import 'package:youtipie/class/result_wrapper/history_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/channel_info_item.dart'; +import 'package:youtipie/youtipie.dart'; + +import 'package:namida/class/route.dart'; +import 'package:namida/core/dimensions.dart'; +import 'package:namida/core/enums.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_info_controller.dart'; +import 'package:namida/youtube/pages/youtube_main_page_fetcher_acc_base.dart'; +import 'package:namida/youtube/widgets/yt_history_video_card.dart'; +import 'package:namida/youtube/widgets/yt_video_card.dart'; + +class YoutubeUserHistoryPage extends StatelessWidget with NamidaRouteWidget { + @override + RouteType get route => RouteType.YOUTUBE_HISTORY_HOSTED_SUBPAGE; + + final void Function(YoutiPieHistoryResult? result)? onListUpdated; + const YoutubeUserHistoryPage({super.key, required this.onListUpdated}); + + @override + Widget build(BuildContext context) { + const multiplier = 1; + const thumbnailHeight = multiplier * Dimensions.youtubeThumbnailHeight; + const thumbnailWidth = multiplier * Dimensions.youtubeThumbnailWidth; + const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; + + const beforeSublistHeight = 24.0; + const afterSublistHeight = 16.0; + + const dummyCard = YoutubeVideoCardDummy( + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + shimmerEnabled: true, + ); + + return YoutubeMainPageFetcherAccBase( + onListUpdated: onListUpdated, + transparentShimmer: true, + title: lang.HISTORY, + cacheReader: YoutiPie.cacheBuilder.forHistoryVideos(), + networkFetcher: (details) => YoutubeInfoController.history.fetchHistory(details: details), + itemExtent: thumbnailItemExtent, + dummyCard: dummyCard, + itemBuilder: (chunk, index, list) { + final items = chunk.items; + + final hasBeforeAndAfterPadding = chunk.title.isNotEmpty; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasBeforeAndAfterPadding) + SizedBox( + height: beforeSublistHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + chunk.title, + style: context.textTheme.displayMedium, + ), + ), + ), + SizedBox( + height: items.length * thumbnailItemExtent, + child: ListView.builder( + padding: EdgeInsets.zero, + scrollDirection: Axis.vertical, + primary: false, + physics: const NeverScrollableScrollPhysics(), + itemExtent: thumbnailItemExtent, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return switch (item.runtimeType) { + const (StreamInfoItem) => YoutubeVideoCard( + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + isImageImportantInCache: false, + video: item as StreamInfoItem, + playlistID: null, + ), + const (StreamInfoItemShort) => YoutubeShortVideoCard( + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + short: item as StreamInfoItemShort, + playlistID: null, + ), + _ => dummyCard, + }; + }, + ), + ), + if (hasBeforeAndAfterPadding) const SizedBox(height: afterSublistHeight), + ], + ); + }, + sliverListBuilder: (listItems, itemBuilder, dummyCard) => SliverVariedExtentList.builder( + itemExtentBuilder: (index, dimensions) { + final chunk = listItems.items[index]; + final hasBeforeAndAfterPadding = chunk.title.isNotEmpty; + double itemsExtent = chunk.items.length * thumbnailItemExtent; + if (hasBeforeAndAfterPadding) { + itemsExtent += beforeSublistHeight; + itemsExtent += afterSublistHeight; + } + return itemsExtent; + }, + itemCount: listItems.items.length, + itemBuilder: (context, index) { + final chunk = listItems.items[index]; + return itemBuilder(chunk, index, listItems); + }, + ), + ); + } +} + +class YoutubeUserHistoryPageHorizontal extends StatelessWidget { + final GlobalKey? pageKey; + const YoutubeUserHistoryPageHorizontal({super.key, this.pageKey}); + + @override + Widget build(BuildContext context) { + const multiplier = 1.0; + const horizontalHeight = multiplier * Dimensions.youtubeCardItemHeight * 1.6; + const thumbnailHeight = multiplier * horizontalHeight * 0.6; + const thumbnailWidth = thumbnailHeight * 16 / 9; + const thumbnailItemExtent = thumbnailWidth; + + final dummyCard = NamidaInkWell( + animationDurationMS: 200, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + width: thumbnailWidth, + height: thumbnailHeight, + bgColor: context.theme.cardColor, + ); + + return YoutubeMainPageFetcherAccBase( + key: pageKey, + isHorizontal: true, + horizontalHeight: horizontalHeight, + enablePullToRefresh: false, + transparentShimmer: true, + topPadding: 12.0, + title: lang.HISTORY, + onHeaderTap: YoutubeUserHistoryPage( + onListUpdated: (result) { + if (result == null) return; + (pageKey?.currentState as dynamic)?.updateList(result); + }, + ).navigate, + cacheReader: YoutiPie.cacheBuilder.forHistoryVideos(), + networkFetcher: (details) => YoutubeInfoController.history.fetchHistory(details: details), + itemExtent: thumbnailItemExtent, + dummyCard: dummyCard, + itemBuilder: (chunk, chunkIndex, list) { + final items = chunk.items; + return SizedBox( + height: horizontalHeight, + width: items.length * thumbnailItemExtent, + child: ListView.builder( + scrollDirection: Axis.horizontal, + primary: false, + itemExtent: thumbnailItemExtent, + itemCount: items.length, + itemBuilder: (context, index) { + return YTHistoryVideoCardBase( + mainList: items, + itemToYTVideoId: (e) { + if (e is StreamInfoItem) { + return (e.id, null); + } else if (e is StreamInfoItemShort) { + return (e.id, null); + } + throw Exception('itemToYTID unknown type'); + }, + day: null, + index: index, + playlistID: null, + playlistName: lang.HISTORY, + canHaveDuplicates: true, + minimalCard: true, + info: (item) { + if (item is StreamInfoItem) { + return item; + } + if (item is StreamInfoItemShort) { + return StreamInfoItem( + id: item.id, + title: item.title, + shortDescription: null, + channel: const ChannelInfoItem.anonymous(), + thumbnailGifUrl: null, + publishedFromText: '', + publishedAt: const PublishTime.unknown(), + indexInPlaylist: null, + durSeconds: null, + durText: null, + viewsText: item.viewsText, + viewsCount: item.viewsCount, + percentageWatched: null, + liveThumbs: item.liveThumbs, + isUploaderVerified: null, + badges: null, + ); + } + return null; + }, + thumbnailHeight: thumbnailHeight, + minimalCardWidth: thumbnailWidth, + ); + }, + ), + ); + }, + sliverListBuilder: (listItems, itemBuilder, dummyCard) => SliverVariedExtentList.builder( + itemExtentBuilder: (index, dimensions) { + final chunk = listItems.items[index]; + return chunk.items.length * thumbnailItemExtent; + }, + itemCount: listItems.items.length, + itemBuilder: (context, index) { + final chunk = listItems.items[index]; + return itemBuilder(chunk, index, listItems); + }, + ), + ); + } +} diff --git a/lib/youtube/pages/youtube_user_playlists_page.dart b/lib/youtube/pages/youtube_user_playlists_page.dart index 1980addf..d9074f44 100644 --- a/lib/youtube/pages/youtube_user_playlists_page.dart +++ b/lib/youtube/pages/youtube_user_playlists_page.dart @@ -7,11 +7,12 @@ import 'package:namida/core/dimensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/pages/youtube_main_page_fetcher_acc_base.dart'; +import 'package:namida/youtube/pages/youtube_user_history_page.dart'; import 'package:namida/youtube/widgets/yt_playlist_card.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; -class YoutubePlaylistsPage extends StatelessWidget { - const YoutubePlaylistsPage({super.key}); +class YoutubeUserPlaylistsPage extends StatelessWidget { + const YoutubeUserPlaylistsPage({super.key}); @override Widget build(BuildContext context) { @@ -19,9 +20,13 @@ class YoutubePlaylistsPage extends StatelessWidget { const thumbnailHeight = multiplier * Dimensions.youtubeThumbnailHeight; const thumbnailWidth = multiplier * Dimensions.youtubeThumbnailWidth; const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; - + final horizontalHistoryKey = GlobalKey(); + final horizontalHistory = YoutubeUserHistoryPageHorizontal(pageKey: horizontalHistoryKey); return YoutubeMainPageFetcherAccBase( transparentShimmer: true, + topPadding: 12.0, + pageHeader: horizontalHistory, + onPullToRefresh: () => (horizontalHistoryKey.currentState as dynamic)?.forceFetchFeed() as Future, title: lang.PLAYLISTS, cacheReader: YoutiPie.cacheBuilder.forUserPlaylists(), networkFetcher: (details) => YoutubeInfoController.userplaylist.getUserPlaylists(details: details), @@ -35,7 +40,7 @@ class YoutubePlaylistsPage extends StatelessWidget { return YoutubePlaylistCard( key: Key(playlist.id), playlist: playlist, - subtitle: playlist.infoTexts?.join(' - '), + subtitle: playlist.infoTexts?.firstOrNull, // the second text is mostly like 'updated today' etc thumbnailWidth: thumbnailWidth, thumbnailHeight: thumbnailHeight, firstVideoID: null, diff --git a/lib/youtube/pages/yt_playlist_subpage.dart b/lib/youtube/pages/yt_playlist_subpage.dart index 3c0932cb..2ba369cf 100644 --- a/lib/youtube/pages/yt_playlist_subpage.dart +++ b/lib/youtube/pages/yt_playlist_subpage.dart @@ -436,8 +436,8 @@ class _YTHostedPlaylistSubpageState extends State with return PlaylistID(id: plId); } - Future _fetch100Video({bool forceRequest = false}) async { - if (_isLoadingMoreItems.value) return; + Future _fetch100Video({bool forceRequest = false}) async { + if (_isLoadingMoreItems.value) return false; _isLoadingMoreItems.value = true; bool fetched = false; @@ -461,6 +461,7 @@ class _YTHostedPlaylistSubpageState extends State with _isLoadingMoreItems.value = false; if (fetched) refreshState(trySortStreams); + return fetched; } @override diff --git a/lib/youtube/pages/yt_search_results_page.dart b/lib/youtube/pages/yt_search_results_page.dart index 48ce128e..164d691e 100644 --- a/lib/youtube/pages/yt_search_results_page.dart +++ b/lib/youtube/pages/yt_search_results_page.dart @@ -103,15 +103,17 @@ class YoutubeSearchResultsPageState extends State with refreshState(); } - Future _fetchSearchNextPage() async { + Future _fetchSearchNextPage() async { + bool fetched = false; final searchRes = _searchResult; - if (searchRes == null) return; // return if still fetching first results. - if (!searchRes.canFetchNext) return; - if (!ConnectivityController.inst.hasConnection) return; + if (searchRes == null) return fetched; // return if still fetching first results. + if (!searchRes.canFetchNext) return fetched; + if (!ConnectivityController.inst.hasConnection) return fetched; _isFetchingMoreResults.value = true; - await searchRes.fetchNext(); + fetched = await searchRes.fetchNext(); _isFetchingMoreResults.value = false; refreshState(); + return fetched; } @override diff --git a/lib/youtube/widgets/yt_history_video_card.dart b/lib/youtube/widgets/yt_history_video_card.dart index 241a34ca..72e70c31 100644 --- a/lib/youtube/widgets/yt_history_video_card.dart +++ b/lib/youtube/widgets/yt_history_video_card.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:playlist_manager/module/playlist_id.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:youtipie/youtipie.dart'; import 'package:namida/class/track.dart'; +import 'package:namida/class/video.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/player_controller.dart'; import 'package:namida/core/dimensions.dart'; @@ -15,6 +16,7 @@ import 'package:namida/core/utils.dart'; import 'package:namida/ui/pages/subpages/playlist_tracks_subpage.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/yt_utils.dart'; @@ -72,21 +74,125 @@ class YTHistoryVideoCard extends StatelessWidget { @override Widget build(BuildContext context) { - final index = reversedList ? videos.length - 1 - this.index : this.index; - final video = videos[index] as YoutubeID; - final thumbHeight = thumbnailHeight ?? (minimalCard ? 24.0 * 3.2 : Dimensions.youtubeCardItemHeight); - final thumbWidth = minimalCardWidth ?? thumbHeight * 16 / 9; + return YTHistoryVideoCardBase( + mainList: videos, + itemToYTVideoId: (e) { + e as YoutubeID; + return (e.id, e.watchNull); + }, + day: day, + index: index, + overrideListens: overrideListens, + playlistID: playlistID, + minimalCard: minimalCard, + displayTimeAgo: displayTimeAgo, + thumbnailHeight: thumbnailHeight, + minimalCardWidth: minimalCardWidth, + reversedList: reversedList, + playlistName: playlistName, + openMenuOnLongPress: openMenuOnLongPress, + fromPlayerQueue: fromPlayerQueue, + draggableThumbnail: draggableThumbnail, + draggingEnabled: draggingEnabled, + showMoreIcon: showMoreIcon, + draggingBarsBuilder: draggingBarsBuilder, + draggingThumbnailBuilder: draggingThumbnailBuilder, + cardColorOpacity: cardColorOpacity, + fadeOpacity: fadeOpacity, + isImportantInCache: isImportantInCache, + bgColor: bgColor, + canHaveDuplicates: canHaveDuplicates, + info: null, + ); + } +} + +class YTHistoryVideoCardBase extends StatelessWidget { + final List mainList; + final (String, YTWatch?) Function(T item) itemToYTVideoId; + final int? day; + final int index; + final List overrideListens; + final PlaylistID? playlistID; + final bool minimalCard; + final bool displayTimeAgo; + final double? thumbnailHeight; + final double? minimalCardWidth; + final bool reversedList; + final String playlistName; + final bool openMenuOnLongPress; + final bool fromPlayerQueue; + final bool draggableThumbnail; + final bool draggingEnabled; + final bool showMoreIcon; + final Widget Function(Color? color)? draggingBarsBuilder; + final Widget Function(Widget draggingTrigger)? draggingThumbnailBuilder; + final double cardColorOpacity; + final double fadeOpacity; + final bool isImportantInCache; + final Color? bgColor; + final bool canHaveDuplicates; + final StreamInfoItem? Function(T item)? info; + + const YTHistoryVideoCardBase({ + super.key, + required this.mainList, + required this.itemToYTVideoId, + required this.day, + required this.index, + this.overrideListens = const [], + required this.playlistID, + this.minimalCard = false, + this.displayTimeAgo = true, + this.thumbnailHeight, + this.minimalCardWidth, + this.reversedList = false, + required this.playlistName, + this.openMenuOnLongPress = true, + this.fromPlayerQueue = false, + this.draggableThumbnail = false, + this.draggingEnabled = false, + this.showMoreIcon = false, + this.draggingBarsBuilder, + this.draggingThumbnailBuilder, + this.cardColorOpacity = 0.75, + this.fadeOpacity = 0, + this.isImportantInCache = true, + this.bgColor, + required this.canHaveDuplicates, + required this.info, + }); + + YoutubeID itemToYTIDPlay(T item) { + final e = itemToYTVideoId(item); + return YoutubeID(id: e.$1, watchNull: e.$2, playlistID: playlistID); + } + + @override + Widget build(BuildContext context) { + final index = reversedList ? mainList.length - 1 - this.index : this.index; + final item = mainList[index]; + final videoIdWatch = itemToYTVideoId(item); + final videoId = videoIdWatch.$1; + final videoWatch = videoIdWatch.$2; + double thumbHeight = thumbnailHeight ?? (minimalCard ? 24.0 * 3.2 : Dimensions.youtubeCardItemHeight); + double thumbWidth = minimalCardWidth ?? thumbHeight * 16 / 9; + if (minimalCard) { + // this might crop the image since we enabling forceSquared. + thumbHeight -= 3.0; + thumbWidth -= 3.0; + } - final info = YoutubeInfoController.utils.getStreamInfoSync(video.id) /* ?? YoutubeInfoController.video.fetchVideoPageSync(video.id) */; + final info = this.info?.call(item) ?? YoutubeInfoController.utils.getStreamInfoSync(videoId) /* ?? YoutubeInfoController.video.fetchVideoPageSync(videoId) */; final duration = info?.durSeconds?.secondsLabel; - final videoTitle = info?.title ?? YoutubeInfoController.utils.getVideoName(video.id) ?? video.id; - final videoChannel = info?.channelName ?? info?.channel.title ?? YoutubeInfoController.utils.getVideoChannelName(video.id); - final watchMS = video.dateTimeAdded.millisecondsSinceEpoch; - final dateText = !displayTimeAgo - ? '' - : minimalCard - ? Jiffy.parseFromMillisecondsSinceEpoch(watchMS).fromNow() - : watchMS.dateAndClockFormattedOriginal; + final videoTitle = info?.title ?? YoutubeInfoController.utils.getVideoName(videoId) ?? videoId; + final videoChannel = info?.channelName ?? info?.channel.title ?? YoutubeInfoController.utils.getVideoChannelName(videoId); + + String? dateText; + if (displayTimeAgo) { + final watchMS = videoWatch?.date.millisecondsSinceEpoch; + if (watchMS != null) dateText = minimalCard ? Jiffy.parseFromMillisecondsSinceEpoch(watchMS).fromNow() : watchMS.dateAndClockFormattedOriginal; + } final draggingThumbWidget = draggableThumbnail && draggingEnabled ? NamidaReordererableListener( @@ -104,13 +210,13 @@ class YTHistoryVideoCard extends StatelessWidget { openOnTap: false, openOnLongPress: openMenuOnLongPress, childrenDefault: () => YTUtils.getVideoCardMenuItems( - videoId: video.id, + videoId: videoId, url: info?.buildUrl(), channelID: info?.channelId ?? info?.channel.id, playlistID: playlistID, - idsNamesLookup: {video.id: info?.title}, + idsNamesLookup: {videoId: info?.title}, playlistName: playlistName, - videoYTID: video, + videoYTID: itemToYTIDPlay(item), ), child: Obx( () { @@ -121,7 +227,13 @@ class YTHistoryVideoCard extends StatelessWidget { } final bool isRightIndex = canHaveDuplicates ? index == Player.inst.currentIndex.valueR : true; - final bool isCurrentlyPlaying = isRightIndex && Player.inst.currentVideoR == video; + bool isCurrentlyPlaying = false; + + if (isRightIndex) { + final curr = Player.inst.currentVideoR; + if (videoId == curr?.id && videoIdWatch.$2 == curr?.watchNull) isCurrentlyPlaying = true; + } + final itemsColor7 = isCurrentlyPlaying ? Colors.white.withOpacity(0.7) : null; final itemsColor6 = isCurrentlyPlaying ? Colors.white.withOpacity(0.6) : null; final itemsColor5 = isCurrentlyPlaying ? Colors.white.withOpacity(0.5) : null; @@ -138,15 +250,17 @@ class YTHistoryVideoCard extends StatelessWidget { Center( child: YoutubeThumbnail( type: ThumbnailType.video, - key: Key(video.id), + key: Key(videoId), borderRadius: 8.0, isImportantInCache: isImportantInCache, - width: thumbWidth - 3.0, - height: thumbHeight - 3.0, - videoId: video.id, + width: thumbWidth, + height: thumbHeight, + videoId: videoId, + preferLowerRes: true, customUrl: info?.liveThumbs.pick()?.url, smallBoxText: duration, smallBoxIcon: willSleepAfterThis ? Broken.timer_1 : null, + forceSquared: true, // -- if false, low quality images with black bars would appear ), ), if (draggingThumbWidget != null) draggingThumbnailBuilder?.call(draggingThumbWidget) ?? draggingThumbWidget @@ -179,7 +293,7 @@ class YTHistoryVideoCard extends StatelessWidget { color: itemsColor6, ), ), - if (dateText != '') + if (dateText != null && dateText.isNotEmpty) Text( dateText, maxLines: 1, @@ -209,12 +323,10 @@ class YTHistoryVideoCard extends StatelessWidget { } } else { Player.inst.playOrPause( - this.index, - (reversedList ? videos.reversed : videos).map((e) { - e as YoutubeID; - return YoutubeID(id: e.id, watchNull: e.watchNull, playlistID: playlistID); - }), - QueueSource.others); + this.index, + (reversedList ? mainList.reversed : mainList).map(itemToYTIDPlay), + QueueSource.others, + ); } }, height: minimalCard ? null : Dimensions.youtubeCardItemExtent, @@ -243,7 +355,7 @@ class YTHistoryVideoCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: YTUtils.getVideoCacheStatusIcons( context: context, - videoId: video.id, + videoId: videoId, iconsColor: itemsColor5, overrideListens: overrideListens, displayCacheIcons: !minimalCard, @@ -256,11 +368,11 @@ class YTHistoryVideoCard extends StatelessWidget { right: 0.0, child: NamidaPopupWrapper( childrenDefault: () => YTUtils.getVideoCardMenuItems( - videoId: video.id, + videoId: videoId, url: info?.buildUrl(), channelID: info?.channelId ?? info?.channel.id, playlistID: playlistID, - idsNamesLookup: {video.id: videoTitle}, + idsNamesLookup: {videoId: videoTitle}, ), child: Padding( padding: const EdgeInsets.all(8.0), diff --git a/lib/youtube/youtube_miniplayer.dart b/lib/youtube/youtube_miniplayer.dart index 2a30e002..0dec0dd6 100644 --- a/lib/youtube/youtube_miniplayer.dart +++ b/lib/youtube/youtube_miniplayer.dart @@ -384,7 +384,7 @@ class YoutubeMiniPlayerState extends State { ignoring: !_canScrollQueue, child: LazyLoadListView( key: Key("${currentId}_body_lazy_load_list"), - onReachingEnd: ytTopComments ? () {} : () => YoutubeInfoController.current.updateCurrentComments(currentId), + onReachingEnd: ytTopComments ? () => false : () => YoutubeInfoController.current.updateCurrentComments(currentId), extend: 400, scrollController: _scrollController, listview: (controller) => Stack( diff --git a/lib/youtube/yt_minplayer_comment_replies_subpage.dart b/lib/youtube/yt_minplayer_comment_replies_subpage.dart index bf3450a4..23fcc8b7 100644 --- a/lib/youtube/yt_minplayer_comment_replies_subpage.dart +++ b/lib/youtube/yt_minplayer_comment_replies_subpage.dart @@ -96,14 +96,16 @@ class _YTMiniplayerCommentRepliesSubpageState extends State _fetchRepliesNext() async { + Future _fetchRepliesNext() async { + bool fetched = false; final replies = _currentReplies; - if (replies.value?.canFetchNext != true) return; + if (replies.value?.canFetchNext != true) return fetched; _isLoadingMoreReplies.value = true; - final fetched = await replies.value?.fetchNext(); + fetched = await replies.value?.fetchNext() ?? false; if (fetched == true) replies.refresh(); _isLoadingMoreReplies.value = false; + return fetched; } @override diff --git a/pubspec.yaml b/pubspec.yaml index cb6ec066..d4734306 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: namida description: A Beautiful and Feature-rich Music Player, With YouTube & Video Support Built in Flutter publish_to: "none" -version: 3.2.5-beta+240713238 +version: 3.2.7-beta+240715234 environment: sdk: ">=3.4.0 <4.0.0"