From cc66282fe152bec3175b6869709a6e3478944b6d Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Mon, 24 Jun 2024 21:26:25 +0300 Subject: [PATCH] core: migrate to youtipie ref: #227 --- README.md | 2 +- android/app/build.gradle | 8 +- lib/base/audio_handler.dart | 466 ++-- lib/base/youtube_channel_controller.dart | 52 +- lib/base/youtube_streams_manager.dart | 127 +- lib/controller/backup_controller.dart | 4 +- lib/controller/json_to_history_parser.dart | 4 +- lib/controller/logs_controller.dart | 6 +- .../lrc_search_utils_base.dart | 6 +- lib/controller/player_controller.dart | 47 +- lib/controller/playlist_controller.dart | 4 +- lib/controller/thumbnail_manager.dart | 78 +- lib/controller/video_controller.dart | 71 +- lib/core/constants.dart | 9 + lib/core/functions.dart | 36 +- lib/core/namida_converter_ext.dart | 97 +- lib/core/translations/language.dart | 6 +- lib/core/translations/static_strings.dart | 8 + lib/main.dart | 20 +- lib/packages/miniplayer.dart | 59 +- lib/packages/miniplayer_base.dart | 20 +- lib/ui/dialogs/edit_tags_dialog.dart | 24 +- lib/ui/pages/main_page.dart | 12 +- lib/ui/pages/search_page.dart | 15 +- .../widgets/settings/advanced_settings.dart | 3 +- lib/ui/widgets/settings/youtube_settings.dart | 4 +- lib/ui/widgets/video_widget.dart | 622 ++--- lib/youtube/class/youtube_id.dart | 25 +- .../class/youtube_item_download_config.dart | 25 +- lib/youtube/class/yt_thumbnail_wrapper.dart | 13 - .../yt_channel_info_controller.dart | 36 + .../yt_search_info_controller.dart | 26 + .../info_controllers/yt_various_utils.dart | 86 + .../yt_video_info_controller.dart | 48 + .../controller/youtube_controller.dart | 731 +----- .../controller/youtube_current_info.dart | 148 ++ .../controller/youtube_import_controller.dart | 2 +- .../controller/youtube_info_controller.dart | 68 + .../youtube_local_search_controller.dart | 217 +- .../youtube_playlist_controller.dart | 8 +- .../youtube_subscriptions_controller.dart | 17 +- .../controller/yt_generators_controller.dart | 113 +- .../yt_miniplayer_ui_controller.dart | 25 + .../functions/add_to_playlist_sheet.dart | 4 +- lib/youtube/functions/download_sheet.dart | 257 ++- lib/youtube/functions/yt_playlist_utils.dart | 268 +-- lib/youtube/pages/youtube_page.dart | 70 +- lib/youtube/pages/yt_channel_subpage.dart | 93 +- lib/youtube/pages/yt_channels_page.dart | 103 +- .../pages/yt_local_search_results.dart | 9 +- .../pages/yt_playlist_download_subpage.dart | 29 +- lib/youtube/pages/yt_playlist_subpage.dart | 146 +- lib/youtube/pages/yt_search_results_page.dart | 205 +- lib/youtube/widgets/yt_card.dart | 25 +- lib/youtube/widgets/yt_channel_card.dart | 27 +- lib/youtube/widgets/yt_comment_card.dart | 126 +- .../widgets/yt_download_task_item_card.dart | 84 +- .../widgets/yt_history_video_card.dart | 38 +- lib/youtube/widgets/yt_playlist_card.dart | 155 +- lib/youtube/widgets/yt_queue_chip.dart | 17 +- lib/youtube/widgets/yt_subscribe_buttons.dart | 17 +- lib/youtube/widgets/yt_thumbnail.dart | 33 +- lib/youtube/widgets/yt_video_card.dart | 239 +- .../widgets/yt_videos_actions_bar.dart | 106 +- lib/youtube/youtube_miniplayer.dart | 2043 +++++++++-------- .../yt_miniplayer_comments_subpage.dart | 136 +- lib/youtube/yt_utils.dart | 75 +- pubspec.yaml | 6 +- 68 files changed, 4219 insertions(+), 3490 deletions(-) create mode 100644 lib/core/translations/static_strings.dart delete mode 100644 lib/youtube/class/yt_thumbnail_wrapper.dart create mode 100644 lib/youtube/controller/info_controllers/yt_channel_info_controller.dart create mode 100644 lib/youtube/controller/info_controllers/yt_search_info_controller.dart create mode 100644 lib/youtube/controller/info_controllers/yt_various_utils.dart create mode 100644 lib/youtube/controller/info_controllers/yt_video_info_controller.dart create mode 100644 lib/youtube/controller/youtube_current_info.dart create mode 100644 lib/youtube/controller/youtube_info_controller.dart create mode 100644 lib/youtube/controller/yt_miniplayer_ui_controller.dart diff --git a/README.md b/README.md index d1ce67fc..b9787d74 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Animating Thumbnail | Recommends & Listens ### Special Thanks: -> - [@Artx-II](https://github.com/Artx-II) for their initial dart port of Newpipe Extractor, which powers youtube section. +> - [@MSOB7YY](https://github.com/MSOB7YY) for their youtube client, which powers youtube section. > - [@cameralis](https://github.com/cameralis) for their awesome miniplayer physics. > - [@alexmercerind](https://github.com/alexmercerind) for helping me out a lot. > - [@lusaxweb](https://github.com/lusaxweb) for their awesome Iconsax icon pack. diff --git a/android/app/build.gradle b/android/app/build.gradle index f453c444..c455f5db 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,7 +31,7 @@ if (flutterVersionName == null) { android { namespace 'com.msob7y.namida' compileSdkVersion 34 - ndkVersion flutter.ndkVersion + ndkVersion "26.3.11579264" splits { abi { @@ -55,6 +55,12 @@ android { main.java.srcDirs += 'src/main/kotlin' } + packaging { + jniLibs { + exclude("lib/x86/*.so") + } + } + applicationVariants.all { variant -> variant.outputs.all { output -> def abi = output.getFilter(com.android.build.OutputFile.ABI) diff --git a/lib/base/audio_handler.dart b/lib/base/audio_handler.dart index 3309f1b0..e2284ff0 100644 --- a/lib/base/audio_handler.dart +++ b/lib/base/audio_handler.dart @@ -5,10 +5,13 @@ import 'package:audio_service/audio_service.dart'; import 'package:basic_audio_handler/basic_audio_handler.dart'; import 'package:flutter/scheduler.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:namida/core/utils.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:playlist_manager/module/playlist_id.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_stream_info.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/class/audio_cache_detail.dart'; import 'package:namida/class/func_execute_limiter.dart'; @@ -33,11 +36,13 @@ import 'package:namida/core/constants.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/namida_converter_ext.dart'; +import 'package:namida/core/utils.dart'; import 'package:namida/main.dart'; import 'package:namida/ui/dialogs/common_dialogs.dart'; import 'package:namida/youtube/class/youtube_id.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/yt_utils.dart'; class NamidaAudioVideoHandler extends BasicAudioHandler { @@ -78,11 +83,9 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { var audioCacheMap = >{}; - final currentVideoInfo = Rxn(); - final currentChannelInfo = Rxn(); - final currentVideoStream = Rxn(); - final currentAudioStream = Rxn(); - final currentVideoThumbnail = Rxn(); + final currentVideoStream = Rxn(); + final currentAudioStream = Rxn(); + // final currentVideoThumbnail = Rxn(); final currentCachedVideo = Rxn(); final currentCachedAudio = Rxn(); @@ -149,31 +152,49 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { // ================================ Player methods ================================= // ================================================================================= - void refreshNotification([Q? item, VideoInfo? videoInfo]) { + void refreshNotification([Q? item, YoutubeIDToMediaItemCallback? youtubeIdMediaItem]) { final exectuteOn = item ?? currentItem.value; exectuteOn?._execute( selectable: (finalItem) { - _notificationUpdateItem(item: exectuteOn, isItemFavourite: finalItem.track.isFavourite, itemIndex: currentIndex.value); + _notificationUpdateItemSelectable( + item: finalItem, + isItemFavourite: finalItem.track.isFavourite, + itemIndex: currentIndex.value, + ); }, youtubeID: (finalItem) { - _notificationUpdateItem(item: exectuteOn, isItemFavourite: false, itemIndex: currentIndex.value, videoInfo: videoInfo); + _notificationUpdateItemYoutubeID( + item: finalItem, + isItemFavourite: false, // TODO: implement? + itemIndex: currentIndex.value, + youtubeIdMediaItem: youtubeIdMediaItem ?? + (int index, int queueLength) { + final streamInfo = YoutubeInfoController.current.currentYTStreams.value?.info; + final thumbnail = finalItem.getThumbnailSync(); + return finalItem.toMediaItem(streamInfo, thumbnail, index, queueLength); + }, + ); }, ); } - void _notificationUpdateItem({required Q item, required bool isItemFavourite, required int itemIndex, VideoInfo? videoInfo}) { - item._execute( - selectable: (finalItem) async { - mediaItem.add(finalItem.toMediaItem(currentIndex.value, currentQueue.length)); - playbackState.add(transformEvent(PlaybackEvent(currentIndex: currentIndex.value), isItemFavourite, itemIndex)); - }, - youtubeID: (finalItem) async { - final info = videoInfo ?? YoutubeController.inst.getVideoInfo(finalItem.id); - final thumbnail = finalItem.getThumbnailSync(); - mediaItem.add(finalItem.toMediaItem(info, thumbnail, currentIndex.value, currentQueue.length)); - playbackState.add(transformEvent(PlaybackEvent(currentIndex: currentIndex.value), isItemFavourite, itemIndex)); - }, - ); + void _notificationUpdateItemSelectable({ + required Selectable item, + required bool isItemFavourite, + required int itemIndex, + }) { + mediaItem.add(item.toMediaItem(currentIndex.value, currentQueue.value.length)); + playbackState.add(transformEvent(PlaybackEvent(currentIndex: currentIndex.value), isItemFavourite, itemIndex)); + } + + void _notificationUpdateItemYoutubeID({ + required YoutubeID item, + required bool isItemFavourite, + required int itemIndex, + required YoutubeIDToMediaItemCallback youtubeIdMediaItem, + }) { + mediaItem.add(youtubeIdMediaItem(currentIndex.value, currentQueue.value.length)); + playbackState.add(transformEvent(PlaybackEvent(currentIndex: currentIndex.value), isItemFavourite, itemIndex)); } // ================================================================================= @@ -202,7 +223,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { @override void onQueueChanged() async { super.onQueueChanged(); - if (currentQueue.isEmpty) { + if (currentQueue.value.isEmpty) { CurrentColor.inst.resetCurrentPlayingTrack(); if (MiniPlayerController.inst.isInQueue) MiniPlayerController.inst.snapToMini(); // await pause(); @@ -251,7 +272,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { @override FutureOr beforeQueueAddOrInsert(Iterable items) async { - if (currentQueue.isEmpty) return; + if (currentQueue.value.isEmpty) return; // this is what keeps local & youtube separated. this shall be removed if mixed playback ever got supported. final current = currentItem.value; @@ -271,20 +292,13 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { CurrentColor.inst.resetCurrentPlayingTrack(); VideoController.inst.currentVideo.value = null; - VideoController.inst.currentYTQualities.clear(); - VideoController.inst.currentPossibleVideos.clear(); + VideoController.inst.currentYTStreams.value = null; + VideoController.inst.currentPossibleLocalVideos.clear(); - YoutubeController.inst.currentYTQualities.clear(); - YoutubeController.inst.currentYTAudioStreams.clear(); - YoutubeController.inst.currentCachedQualities.clear(); - YoutubeController.inst.currentComments.clear(); - YoutubeController.inst.currentRelatedVideos.clear(); + YoutubeInfoController.current.resetAll(); - currentVideoInfo.value = null; - currentChannelInfo.value = null; currentVideoStream.value = null; currentAudioStream.value = null; - currentVideoThumbnail.value = null; currentCachedVideo.value = null; currentCachedAudio.value = null; _isCurrentAudioFromCache = false; @@ -306,28 +320,31 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { /// /// also adds newly cached audios. void fn() async { - final prevVideo = currentVideoInfo.value; - final prevStream = currentVideoStream.value; - final vId = prevVideo?.id; + final prevVideoInfo = YoutubeInfoController.current.currentYTStreams.value?.info; + + String? vId = prevVideoInfo?.id; + if (vId == null) { + final curr = currentItem.value; + if (curr is YoutubeID) vId = curr.id; + } if (vId != null) { // -- Video handling - if (prevVideo != null && prevStream != null) { + final prevStream = currentVideoStream.value; + if (prevStream != null) { final maybeCached = prevStream.getCachedFile(vId); if (maybeCached != null) { - int? parsy(String? s) => s == null ? null : DateTime.tryParse(s)?.millisecondsSinceEpoch; - VideoController.inst.addYTVideoToCacheMap( vId, NamidaVideo( path: maybeCached.path, ytID: vId, - height: prevStream.height ?? 0, - width: prevStream.width ?? 0, - sizeInBytes: prevStream.sizeInBytes ?? 0, - frameratePrecise: prevStream.fps?.toDouble() ?? 0.0, - creationTimeMS: prevVideo.date?.millisecondsSinceEpoch ?? parsy(prevVideo.textualUploadDate) ?? 0, - durationMS: prevStream.durationMS ?? 0, - bitrate: prevStream.bitrate ?? 0, + height: prevStream.height, + width: prevStream.width, + sizeInBytes: prevStream.sizeInBytes, + frameratePrecise: prevStream.fps.toDouble(), + creationTimeMS: (prevVideoInfo?.publishedAt.date ?? prevVideoInfo?.publishDate.date)?.millisecondsSinceEpoch ?? 0, + durationMS: prevStream.duration.inMilliseconds, + bitrate: prevStream.bitrate, ), ); } @@ -407,8 +424,8 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } }, youtubeID: (finalItem) async { - final dur = await finalItem.getDuration(); - return dur?.inSeconds ?? 0; + final durSecCache = YoutubeInfoController.utils.getVideoDurationSeconds(finalItem.id); + return durSecCache; }, )) ?? 0; @@ -423,7 +440,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { track: finalItem.track, source: TrackSource.local, ); - HistoryController.inst.addTracksToHistory([newTrackWithDate]); + await HistoryController.inst.addTracksToHistory([newTrackWithDate]); }, youtubeID: (finalItem) async { final newListen = YoutubeID( @@ -509,7 +526,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { Future setPls() async { if (!File(tr.path).existsSync()) throw PathNotFoundException(tr.path, const OSError(), 'Track file not found or couldn\'t be accessed.'); final dur = await setSource( - tr.toAudioSource(currentIndex.value, currentQueue.length), + tr.toAudioSource(currentIndex.value, currentQueue.value.length), item: pi, startPlaying: startPlaying, videoOptions: initialVideo == null @@ -563,7 +580,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { playErrorRemainingSecondsToSkip.value--; if (playErrorRemainingSecondsToSkip.value <= 0) { NamidaNavigator.inst.closeDialog(); - if (currentQueue.length > 1) skipItem(); + if (currentQueue.value.length > 1) skipItem(); timer.cancel(); } }, @@ -599,7 +616,8 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } Future onItemPlayYoutubeIDSetQuality({ - required VideoOnlyStream? stream, + required VideoStreamsResult? mainStreams, + required VideoStream? stream, required File? cachedFile, required bool useCache, required String videoId, @@ -611,52 +629,76 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { currentVideoStream.value = stream; currentCachedVideo.value = null; - if (cachedFile != null && useCache) { + if (useCache && cachedFile != null && cachedFile.existsSync()) { currentCachedVideo.value = videoItem; await setVideoSource(source: cachedFile.path, isFile: true); - } else if (stream != null && stream.url != null) { + } else if (stream != null) { if (wasPlaying) await onPauseRaw(); - try { + + bool expired = false; + if (mainStreams == null) { + expired = true; + } else { + expired = mainStreams.hasExpired() ?? true; + } + + bool checkInterrupted() { + final curr = currentItem.value; + return curr is YoutubeID && curr.id == videoId; + } + + Future setVideoAndPlay(String url) async { await setVideoSource( - source: stream.url!, + source: url, cacheKey: stream.cacheKey(videoId), ); + refreshNotification(); + } + + try { + if (expired) throw Exception('expired streams'); + final url = stream.buildUrl(); + if (url == null) throw Exception('null url'); + await setVideoAndPlay(url); } catch (e) { // ==== if the url got outdated. isFetchingInfo.value = true; - final newStreams = await YoutubeController.inst.getAvailableVideoStreamsOnly(videoId); + final newStreams = await YoutubeInfoController.video.fetchVideoStreams(videoId); isFetchingInfo.value = false; - final sameStream = newStreams.firstWhereEff((e) => e.resolution == stream.resolution && e.formatSuffix == stream.formatSuffix); - final sameStreamUrl = sameStream?.url; - if (currentItem.value is YoutubeID && videoId != (currentItem.value as YoutubeID).id) return; - - YoutubeController.inst.currentYTQualities.value = newStreams; + if (checkInterrupted()) return; + if (newStreams != null) YoutubeInfoController.current.currentYTStreams.value = newStreams; + VideoStream? sameStream = newStreams?.videoStreams.firstWhereEff((e) => e.itag == stream.itag); + if (sameStream == null && newStreams != null) { + YoutubeController.inst.getPreferredStreamQuality(newStreams.videoStreams, preferIncludeWebm: false); + } + final sameStreamUrl = sameStream?.buildUrl(); if (sameStreamUrl != null) { - await setVideoSource( - source: sameStreamUrl, - cacheKey: stream.cacheKey(videoId), - ); + try { + await setVideoAndPlay(sameStreamUrl); + } catch (_) {} } } + if (wasPlaying) onPlayRaw(); } } Future onItemPlayYoutubeIDSetAudio({ - required AudioOnlyStream? stream, + required VideoStreamsResult? mainStreams, + required AudioStream? stream, required File? cachedFile, required bool useCache, required String videoId, }) async { - final position = currentPositionMS; final wasPlaying = isPlaying.value; currentAudioStream.value = stream; final cachedAudio = stream?.getCachedFile(videoId); - if (cachedAudio != null && useCache) { + + if (useCache && cachedAudio != null && cachedAudio.existsSync()) { await setSource( AudioSource.file(cachedAudio.path, tag: mediaItem), item: currentItem.value, @@ -665,13 +707,20 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { cachedAudioPath: cachedAudio.path, ); refreshNotification(); - } else if (stream != null && stream.url != null) { + } else if (stream != null) { if (wasPlaying) await super.onPauseRaw(); - Future setAudioLockCache() async { + bool expired = false; + if (mainStreams == null) { + expired = true; + } else { + expired = mainStreams.hasExpired() ?? true; + } + + Future setAudioLockCache(String url) async { await setSource( LockCachingAudioSource( - Uri.parse(stream.url!), + Uri.parse(url), cacheFile: File(stream.cachePath(videoId)), tag: mediaItem, onCacheDone: (cacheFile) async { @@ -685,28 +734,35 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { refreshNotification(); } + bool checkInterrupted() { + final curr = currentItem.value; + return curr is YoutubeID && curr.id == videoId; + } + try { - await setAudioLockCache(); - } catch (e) { + if (expired) throw Exception('expired streams'); + final url = stream.buildUrl(); + if (url == null) throw Exception('null url'); + await setAudioLockCache(url); + } catch (_) { // ==== if the url got outdated. isFetchingInfo.value = true; - final newStreams = await YoutubeController.inst.getAvailableAudioOnlyStreams(videoId); + final newStreams = await YoutubeInfoController.video.fetchVideoStreams(videoId); isFetchingInfo.value = false; - final sameStream = newStreams.firstWhereEff((e) => e.bitrate == stream.bitrate && e.formatSuffix == stream.formatSuffix); - final sameStreamUrl = sameStream?.url; - - if (currentItem.value is YoutubeID && videoId != (currentItem.value as YoutubeID).id) return; - YoutubeController.inst.currentYTAudioStreams.value = newStreams; + if (checkInterrupted()) return; + if (newStreams != null) YoutubeInfoController.current.currentYTStreams.value = newStreams; + final sameStream = newStreams?.audioStreams.firstWhereEff((e) => e.itag == stream.itag) ?? newStreams?.audioStreams.firstNonWebm(); + final sameStreamUrl = sameStream?.buildUrl(); if (sameStreamUrl != null) { - await setAudioLockCache(); + try { + await setAudioLockCache(sameStreamUrl); + } catch (_) {} } } - await seek(position.value.milliseconds); - if (wasPlaying) { - onPlayRaw(); - } + + if (wasPlaying) onPlayRaw(); } } @@ -734,10 +790,16 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { // -- Audio handling final prevAudioStream = currentAudioStream.value; final prevAudioBitrate = prevAudioStream?.bitrate ?? currentCachedAudio.value?.bitrate; - final prevAudioLangCode = prevAudioStream?.language ?? currentCachedAudio.value?.langaugeCode; - final prevAudioLangName = prevAudioStream?.displayLanguage ?? currentCachedAudio.value?.langaugeName; - final videoInfo = currentVideoInfo.value; - if (videoInfo?.id == videoId) { + final prevAudioLangCode = prevAudioStream?.audioTrack?.langCode ?? currentCachedAudio.value?.langaugeCode; + final prevAudioLangName = prevAudioStream?.audioTrack?.displayName ?? currentCachedAudio.value?.langaugeName; + final prevVideoInfo = YoutubeInfoController.current.currentYTStreams.value?.info; + + String? vId = prevVideoInfo?.id; + if (vId == null) { + final curr = currentItem.value; + if (curr is YoutubeID) vId = curr.id; + } + if (vId == videoId) { if (audioCacheFile != null) { // -- generating waveform if needed if (WaveformController.inst.isDummy && !settings.youtubeStyleMiniplayer.value) { @@ -763,7 +825,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { )); // -- Writing metadata too - final meta = YTUtils.getMetadataInitialMap(videoId, currentVideoInfo.value); + final meta = YTUtils.getMetadataInitialMap(videoId, prevVideoInfo); await YTUtils.writeAudioMetadata( videoId: videoId, audioFile: audioCacheFile, @@ -787,28 +849,65 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { WaveformController.inst.resetWaveform(); Lyrics.inst.resetLyrics(); - YoutubeController.inst.currentYTQualities.clear(); - YoutubeController.inst.currentYTAudioStreams.clear(); - YoutubeController.inst.currentCachedQualities.clear(); - YoutubeController.inst.updateVideoDetails(item.id); - - currentVideoInfo.value = YoutubeController.inst.getVideoInfo(item.id); - currentChannelInfo.value = YoutubeController.inst.fetchChannelDetailsFromCacheSync(currentVideoInfo.value?.uploaderUrl, checkFromStorage: true); currentVideoStream.value = null; currentAudioStream.value = null; - currentVideoThumbnail.value = null; currentCachedVideo.value = null; currentCachedAudio.value = null; _isCurrentAudioFromCache = false; isFetchingInfo.value = false; _nextSeekSetAudioCache = null; + YoutubeInfoController.current.onVideoPageReset?.call(); if (item.id == '' || item.id == 'null') { - if (currentQueue.length > 1) skipItem(); + if (currentQueue.value.length > 1) skipItem(); return; } - refreshNotification(pi, currentVideoInfo.value); + VideoStreamsResult? streamsResult = YoutubeInfoController.video.fetchVideoStreamsSync(item.id); + + YoutubeInfoController.current.currentYTStreams.value = streamsResult; + final hadCachedVideoPage = YoutubeInfoController.current.updateVideoPageSync(item.id); + final hadCachedComments = YoutubeInfoController.current.updateCurrentCommentsSync(item.id); + + Duration? duration; + + bool checkInterrupted() { + if (item != currentItem.value) { + return true; + } else { + if (duration != null) _currentItemDuration.value = duration; + return false; + } + } + + Future fetchFullVideoPage() async { + await YoutubeInfoController.current.updateVideoPage( + item.id, + forceRequestPage: !hadCachedVideoPage, + forceRequestComments: !hadCachedComments, + ); + } + + VideoStreamInfo? info; + File? videoThumbnail; + bool notificationDidRefreshInfo = false; + bool notificationDidRefreshThumbnail = false; + void onInfoOrThumbObtained({VideoStreamInfo? info, File? thumbnail, bool forceRefreshNoti = false}) { + if (forceRefreshNoti == false && notificationDidRefreshInfo && notificationDidRefreshThumbnail) return; + if (checkInterrupted()) return; + if (info != null) notificationDidRefreshInfo = true; + if (thumbnail != null) notificationDidRefreshThumbnail = true; + refreshNotification(pi, (index, ql) => item.toMediaItem(info, thumbnail, index, ql)); + } + + info = streamsResult?.info; + videoThumbnail = item.getThumbnailSync(); + if (info != null || videoThumbnail != null) { + onInfoOrThumbObtained(info: info, thumbnail: videoThumbnail); + } + if (videoThumbnail == null) { + ThumbnailManager.inst.getYoutubeThumbnailAndCache(id: item.id).then((thumbFile) => onInfoOrThumbObtained(thumbnail: thumbFile)); + } Future plsplsplsPlay(bool wasPlayingFromCache, bool sourceChanged) async { if (startPlaying()) { @@ -865,6 +964,8 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { /// different then it will be set later after fetching. playedFromCacheDetails = await _trySetYTVideoWithoutConnection( item: item, + mediaItemFn: () => item.toMediaItem(info, videoThumbnail, index, currentQueue.value.length), + checkInterrupted: () => item != currentItem.value, index: index, canPlayAudioOnly: canPlayAudioOnlyFromCache, disableVideo: _isAudioOnlyPlayback, @@ -874,23 +975,13 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { possibleLocalFiles: Indexer.inst.allTracksMappedByYTID[item.id] ?? [], ); - Duration? duration = playedFromCacheDetails.duration; - - // race avoidance when playing multiple videos - bool checkInterrupted() { - if (item != currentItem.value) { - return true; - } else { - if (duration != null) _currentItemDuration.value = duration; - return false; - } - } + duration ??= playedFromCacheDetails.duration; - if (checkInterrupted()) return; + if (checkInterrupted()) return; // this also refreshes currentDuration if (!ConnectivityController.inst.hasConnection && playedFromCacheDetails.audio == null) { // -- if no connection and couldnt play from cache, we skip - if (currentQueue.length > 1) skipItem(); + if (currentQueue.value.length > 1) skipItem(); return; } @@ -911,51 +1002,45 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { if (ConnectivityController.inst.hasConnection) { try { - final infoC = Completer(); - final videoStreamsC = Completer>(); - final audiostreamsC = Completer>(); - var audiostreams = []; - var videoStreams = []; - VideoInfo? info; - isFetchingInfo.value = true; - YoutubeController.inst.getAvailableAudioOnlyStreams(item.id).catchError((_) { - snackyy(message: 'Error getting audio streams', top: false, isError: true); - return []; - }).then(audiostreamsC.complete); - YoutubeController.inst.fetchVideoDetails(item.id).catchError((_) => null).then(infoC.complete); - YoutubeController.inst.getAvailableVideoStreamsOnly(item.id).catchError((_) => []).then(videoStreamsC.complete); - - void onVideoStreamsObtained(List value) { - videoStreams = value; - if (!checkInterrupted()) YoutubeController.inst.currentYTQualities.value = value; - } - - void onAudioStreamsObtained(List value) { - audiostreams = value; - if (!checkInterrupted()) YoutubeController.inst.currentYTAudioStreams.value = value; + bool forceRequest = false; + if (streamsResult == null) { + forceRequest = true; + } else { + final expired = streamsResult.hasExpired(); + if (expired == null || expired == true) forceRequest = true; } - void onInfoObtained(VideoInfo? value) { - info = value; - if (!checkInterrupted()) currentVideoInfo.value = value; + if (forceRequest) { + streamsResult = await YoutubeInfoController.video.fetchVideoStreams(item.id).catchError((_) { + snackyy(message: 'Error getting streams', top: false, isError: true); + return null; + }); + onInfoOrThumbObtained(info: streamsResult?.info, forceRefreshNoti: false /* we may need to force refresh if info could have changed */); + if (checkInterrupted()) return; + YoutubeInfoController.current.currentYTStreams.value = streamsResult; + } else { + YoutubeInfoController.current.currentYTStreams.value = streamsResult; } - // -- await video streams only if not audio playback - _isAudioOnlyPlayback ? videoStreamsC.future.then(onVideoStreamsObtained) : await videoStreamsC.future.then(onVideoStreamsObtained); - await audiostreamsC.future.then(onAudioStreamsObtained); - await infoC.future.then(onInfoObtained); if (checkInterrupted()) return; isFetchingInfo.value = false; + fetchFullVideoPage(); + + final audiostreams = streamsResult?.audioStreams ?? []; + final videoStreams = streamsResult?.videoStreams ?? []; + info = streamsResult?.info; + if (info == null && audiostreams.isEmpty && videoStreams.isEmpty) return; if (checkInterrupted()) return; final prefferedVideoStream = _isAudioOnlyPlayback || videoStreams.isEmpty ? null : YoutubeController.inst.getPreferredStreamQuality(videoStreams, preferIncludeWebm: false); - final prefferedAudioStream = audiostreams.firstWhereEff((e) => e.formatSuffix != 'webm' && e.language == 'en') ?? - audiostreams.firstWhereEff((e) => e.formatSuffix != 'webm') ?? - audiostreams.firstOrNull; - if (prefferedAudioStream?.url != null || prefferedVideoStream?.url != null) { + final prefferedAudioStream = + audiostreams.firstWhereEff((e) => !e.isWebm && e.audioTrack?.langCode == 'en') ?? audiostreams.firstWhereEff((e) => !e.isWebm) ?? audiostreams.firstOrNull; + final prefferedAudioStreamUrl = prefferedAudioStream?.buildUrl(); + final prefferedVideoStreamUrl = prefferedVideoStream?.buildUrl(); + if (prefferedAudioStreamUrl != null || prefferedVideoStreamUrl != null) { final cachedVideoSet = playedFromCacheDetails.video; bool isStreamRequiredBetterThanCachedSet = cachedVideoSet == null ? true @@ -971,15 +1056,12 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { currentAudioStream.value = prefferedAudioStream; _isCurrentAudioFromCache = playedFromCacheDetails.audio != null; - currentVideoThumbnail.value = item.getThumbnailSync(); - refreshNotification(pi, currentVideoInfo.value); + if (checkInterrupted()) return; // final cachedVideo = prefferedVideoStream?.getCachedFile(item.id); // final cachedAudio = prefferedAudioStream?.getCachedFile(item.id); - final mediaItem = item.toMediaItem(currentVideoInfo.value, currentVideoThumbnail.value, index, currentQueue.length); - if (checkInterrupted()) return; // -- since we disabled auto switching video streams once played from cache, [isVideoCacheSameAsPrevSet] is dropped. // -- with the new possibility of playing local tracks as audio source, [isAudioCacheSameAsPrevSet] also is dropped. final shouldResetVideoSource = _isAudioOnlyPlayback ? false : playedFromCacheDetails.video == null; @@ -993,7 +1075,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { VideoOptions? videoOptions; if (shouldResetVideoSource && isStreamRequiredBetterThanCachedSet) { videoOptions = VideoOptions( - source: prefferedVideoStream?.url ?? '', + source: prefferedVideoStreamUrl ?? '', enableCaching: true, cacheKey: prefferedVideoStream?.cacheKey(item.id) ?? '', cacheDirectory: _defaultCacheDirectory, @@ -1003,10 +1085,10 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { await playerStoppingSeikoo.future; if (checkInterrupted()) return; - if (shouldResetAudioSource) { + if (shouldResetAudioSource && prefferedAudioStream != null && prefferedAudioStreamUrl != null) { duration = await setSource( LockCachingAudioSource( - Uri.parse(prefferedAudioStream!.url!), + Uri.parse(prefferedAudioStreamUrl), cacheFile: File(prefferedAudioStream.cachePath(item.id)), tag: mediaItem, onCacheDone: (cacheFile) async { @@ -1022,8 +1104,6 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { _latestVideoOptions = videoOptions; await setVideo(videoOptions); } - - refreshNotification(); } } catch (e) { if (checkInterrupted()) return; @@ -1040,6 +1120,8 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { printy(e, isError: true); playedFromCacheDetails = await _trySetYTVideoWithoutConnection( item: item, + mediaItemFn: () => item.toMediaItem(info, videoThumbnail, index, currentQueue.value.length), + checkInterrupted: checkInterrupted, index: index, canPlayAudioOnly: canPlayAudioOnlyFromCache, disableVideo: _isAudioOnlyPlayback, @@ -1058,23 +1140,6 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } } - if (currentVideoInfo.value == null) { - YoutubeController.inst.fetchVideoDetails(item.id).then((details) { - if (currentItem.value == item) { - currentVideoInfo.value = details; - refreshNotification(currentItem.value, currentVideoInfo.value); - } - }); - } - if (currentVideoThumbnail.value == null) { - ThumbnailManager.inst.getYoutubeThumbnailAndCache(id: item.id).then((thumbFile) { - if (currentItem.value == item) { - currentVideoThumbnail.value = thumbFile; - refreshNotification(currentItem.value); - } - }); - } - if (!heyIhandledAudioPlaying) { final didplayfromcache = okaySetFromCache(); await plsplsplsPlay(didplayfromcache, !didplayfromcache); @@ -1084,6 +1149,8 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { /// Returns Audio File and Video File. Future<({AudioCacheDetails? audio, NamidaVideo? video, Duration? duration})> _trySetYTVideoWithoutConnection({ required YoutubeID item, + required MediaItem Function() mediaItemFn, + required bool Function() checkInterrupted, required int index, required bool canPlayAudioOnly, required bool disableVideo, @@ -1103,10 +1170,9 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { (e) => e.frameratePrecise, ); - YoutubeController.inst.currentCachedQualities.value = allCachedVideos; + YoutubeInfoController.current.currentCachedQualities.value = allCachedVideos; final cachedVideo = allCachedVideos.firstWhereEff((e) => File(e.path).existsSync()); - final mediaItem = item.toMediaItem(currentVideoInfo.value, currentVideoThumbnail.value, index, currentQueue.length); // ------ Getting Audio ------ final audioFiles = possibleAudioFiles.isNotEmpty @@ -1131,13 +1197,16 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } } + const nullResult = (audio: null, video: null, duration: null); + // ------ Playing ------ if (cachedVideo != null && cachedAudio != null && !disableVideo) { // -- play audio & video await whatToAwait(); try { + if (checkInterrupted()) return nullResult; final dur = await setSource( - AudioSource.file(cachedAudio.file.path, tag: mediaItem), + AudioSource.file(cachedAudio.file.path, tag: mediaItemFn()), item: item as Q?, startPlaying: startPlaying, videoOptions: VideoOptions( @@ -1150,6 +1219,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { isVideoFile: true, cachedAudioPath: cachedAudio.file.path, ); + if (checkInterrupted()) return nullResult; final audioDetails = AudioCacheDetails( youtubeId: item.id, bitrate: cachedAudio.bitrate, @@ -1157,7 +1227,6 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { langaugeName: cachedAudio.langaugeName, file: cachedAudio.file, ); - refreshNotification(); return (audio: audioDetails, video: cachedVideo, duration: dur); } catch (_) { // error in video is handled internally @@ -1167,8 +1236,9 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } else if (cachedAudio != null && canPlayAudioOnly) { // -- play audio only await whatToAwait(); + if (checkInterrupted()) return nullResult; final dur = await setSource( - AudioSource.file(cachedAudio.file.path, tag: mediaItem), + AudioSource.file(cachedAudio.file.path, tag: mediaItemFn()), item: item as Q?, startPlaying: startPlaying, cachedAudioPath: cachedAudio.file.path, @@ -1180,12 +1250,12 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { langaugeName: cachedAudio.langaugeName, file: cachedAudio.file, ); - refreshNotification(); return (audio: audioDetails, video: null, duration: dur); } - return (audio: null, video: null, duration: null); + return nullResult; } + /// TODO: improve using PortsProvider static List _getCachedAudiosForID(Map map) { final dirPath = map["dirPath"] as String; final id = map["id"] as String; @@ -1237,12 +1307,12 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } @override - FutureOr onNotificationFavouriteButtonPressed(Q item) async { - await item._execute( - selectable: (finalItem) async { - final newStat = await PlaylistController.inst.favouriteButtonOnPressed(finalItem.track); - _notificationUpdateItem( - item: item, + void onNotificationFavouriteButtonPressed(Q item) { + item._execute( + selectable: (finalItem) { + final newStat = PlaylistController.inst.favouriteButtonOnPressed(finalItem.track); + _notificationUpdateItemSelectable( + item: finalItem, itemIndex: currentIndex.value, isItemFavourite: newStat, ); @@ -1252,7 +1322,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } @override - FutureOr onPlayingStateChange(bool isPlaying) { + void onPlayingStateChange(bool isPlaying) { CurrentColor.inst.switchColorPalettes(isPlaying); WakelockController.inst.updatePlayPauseStatus(isPlaying); if (isPlaying) { @@ -1266,12 +1336,12 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } @override - FutureOr onRepeatForNtimesFinish() { + void onRepeatForNtimesFinish() { settings.player.save(repeatMode: RepeatMode.none); } @override - FutureOr onTotalListenTimeIncrease(Map totalTimeInSeconds, String key) async { + void onTotalListenTimeIncrease(Map totalTimeInSeconds, String key) async { final newSeconds = totalTimeInSeconds[key] ?? 0; // saves the file each 20 seconds. @@ -1285,7 +1355,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } @override - FutureOr onItemLastPositionReport(Q? currentItem, int currentPositionMs) async { + void onItemLastPositionReport(Q? currentItem, int currentPositionMs) async { await currentItem?._execute( selectable: (finalItem) async { await _updateTrackLastPosition(finalItem.track, currentPositionMS.value); @@ -1561,15 +1631,15 @@ extension TrackToAudioSourceMediaItem on Selectable { } extension YoutubeIDToMediaItem on YoutubeID { - MediaItem toMediaItem(VideoInfo? videoInfo, File? thumbnail, int currentIndex, int queueLength) { + MediaItem toMediaItem(VideoStreamInfo? videoInfo, File? thumbnail, int currentIndex, int queueLength) { final vi = videoInfo; - final artistAndTitle = vi?.name?.splitArtistAndTitle(); - final videoName = vi?.name; - final channelName = vi?.uploaderName; + final artistAndTitle = vi?.title.splitArtistAndTitle(); + final videoName = vi?.title; + final channelName = vi?.channelName; final title = artistAndTitle?.$2?.keepFeatKeywordsOnly() ?? videoName ?? ''; String? artistName = artistAndTitle?.$1; - if ((artistName == null || artistName == '') && channelName != null) { + if ((artistName == '') && channelName != null) { const topic = '- Topic'; final startIndex = (channelName.length - topic.length).withMinimum(0); artistName = channelName.replaceFirst(topic, '', startIndex).trimAll(); @@ -1583,7 +1653,7 @@ extension YoutubeIDToMediaItem on YoutubeID { displayTitle: videoName, displaySubtitle: channelName, displayDescription: "${currentIndex + 1}/$queueLength", - duration: vi?.duration ?? Duration.zero, + duration: vi?.durSeconds?.seconds ?? Duration.zero, artUri: Uri.file((thumbnail != null && thumbnail.existsSync()) ? thumbnail.path : AppPaths.NAMIDA_LOGO), ); } @@ -1603,3 +1673,5 @@ extension _PlayableExecuter on Playable { return null; } } + +typedef YoutubeIDToMediaItemCallback = MediaItem Function(int index, int queueLength); diff --git a/lib/base/youtube_channel_controller.dart b/lib/base/youtube_channel_controller.dart index 533f29ea..f7189409 100644 --- a/lib/base/youtube_channel_controller.dart +++ b/lib/base/youtube_channel_controller.dart @@ -1,18 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/channels/channel_page_result.dart'; +import 'package:youtipie/class/channels/tabs/channel_tab_videos_result.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:namida/base/youtube_streams_manager.dart'; import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/current_color.dart'; -import 'package:namida/core/extensions.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/youtube/class/youtube_subscription.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; +import 'package:youtipie/youtipie.dart'; abstract class YoutubeChannelController extends State with YoutubeStreamsManager { @override - List get streamsList => _streamsList; + List? get streamsList => channelVideoTab?.items; @override ScrollController get scrollController => uploadsScrollController; @@ -25,7 +27,7 @@ abstract class YoutubeChannelController extends State< late final ScrollController uploadsScrollController = ScrollController(); YoutubeSubscription? channel; - late final _streamsList = []; + YoutiPieChannelTabVideosResult? channelVideoTab; ({DateTime oldest, DateTime newest})? streamsPeakDates; bool isLoadingInitialStreams = true; @@ -41,11 +43,12 @@ abstract class YoutubeChannelController extends State< super.dispose(); } + /// TODO(youtipie): this is not really accurate 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; + final d = e.publishedAt.date; if (d != null) { final ms = d.millisecondsSinceEpoch; if (ms < oldest) { @@ -58,36 +61,43 @@ abstract class YoutubeChannelController extends State< streamsPeakDates = (oldest: DateTime.fromMillisecondsSinceEpoch(oldest), newest: DateTime.fromMillisecondsSinceEpoch(newest)); } - Future fetchChannelStreams(YoutubeSubscription sub) async { - final st = await YoutubeController.inst.getChannelStreams(sub.channelID); + Future fetchChannelStreams(YoutiPieChannelPageResult channelPage) async { + final tab = channelPage.tabs.getVideosTab(); + if (tab == null) return; + final channelID = channelPage.id; + final result = await YoutubeInfoController.channel.fetchChannelTab(channelId: channelID, tab: tab); + if (result == null) return; + this.channelVideoTab = result; + + final st = result.items; updatePeakDates(st); - YoutubeSubscriptionsController.inst.refreshLastFetchedTime(sub.channelID); + YoutubeSubscriptionsController.inst.refreshLastFetchedTime(channelID); setState(() { isLoadingInitialStreams = false; - if (sub.channelID == channel?.channelID) { - streamsList.addAll(st); + if (channelID == channel?.channelID) { trySortStreams(); } }); } - Future fetchStreamsNextPage(YoutubeSubscription? sub) async { + Future fetchStreamsNextPage() async { if (isLoadingMoreUploads.value) return; if (lastLoadingMoreWasEmpty.value) return; + final result = this.channelVideoTab; + if (result == null) return; + isLoadingMoreUploads.value = true; - final st = await YoutubeController.inst.getChannelStreamsNextPage(); - updatePeakDates(st); + final didFetch = await result.fetchNext(); isLoadingMoreUploads.value = false; - if (st.isEmpty) { + + if (didFetch) { + if (result.channelId == channel?.channelID) { + setState(trySortStreams); + } + } else { if (ConnectivityController.inst.hasConnection) lastLoadingMoreWasEmpty.value = true; return; } - if (sub?.channelID == channel?.channelID) { - setState(() { - streamsList.addAll(st); - trySortStreams(); - }); - } } } diff --git a/lib/base/youtube_streams_manager.dart b/lib/base/youtube_streams_manager.dart index 23acc71b..798a8af8 100644 --- a/lib/base/youtube_streams_manager.dart +++ b/lib/base/youtube_streams_manager.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; @@ -14,7 +14,7 @@ enum YTVideosSorting { } mixin YoutubeStreamsManager { - List get streamsList; + List? get streamsList; ScrollController get scrollController; BuildContext get context; Color? get sortChipBGColor; @@ -32,57 +32,60 @@ mixin YoutubeStreamsManager { Widget get sortWidget => SingleChildScrollView( scrollDirection: Axis.horizontal, - child: Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ...YTVideosSorting.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 ? sortChipBGColor : context.theme.cardColor, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - enabled - ? ObxO( - rx: sortingByTop, - builder: (sortingByTop) => StackedIcon( - baseIcon: details.$2, - secondaryIcon: sortingByTop ? Broken.arrow_down_2 : Broken.arrow_up_3, - iconSize: 20.0, - secondaryIconSize: 10.0, - blurRadius: 4.0, - baseIconColor: itemsColor, - // secondaryIconColor: enabled ? context.theme.colorScheme.surface : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ...YTVideosSorting.values.map( + (e) { + final details = sortToTextAndIcon(e); + return ObxO( + rx: sorting, + builder: (s) { + final enabled = s == 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 ? sortChipBGColor : context.theme.cardColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + enabled + ? ObxO( + rx: sortingByTop, + builder: (sortingByTop) => StackedIcon( + baseIcon: details.$2, + secondaryIcon: sortingByTop ? Broken.arrow_down_2 : Broken.arrow_up_3, + iconSize: 20.0, + secondaryIconSize: 10.0, + blurRadius: 4.0, + baseIconColor: itemsColor, + // secondaryIconColor: enabled ? context.theme.colorScheme.surface : null, + ), + ) + : Icon( + details.$2, + size: 20.0, + color: 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: () => onSortChanged( - () => sortStreams(sort: e, sortingByTop: enabled ? !sortingByTop.value : null), - ), - ); - }, - ), - ], - ), + const SizedBox(width: 4.0), + Text( + details.$1, + style: context.textTheme.displayMedium?.copyWith(color: itemsColor), + ), + ], + ), + onTap: () => onSortChanged( + () => sortStreams(sort: e, sortingByTop: enabled ? !sortingByTop.value : null), + ), + ); + }, + ); + }, + ), + ], ), ); void trySortStreams() { @@ -92,20 +95,21 @@ mixin YoutubeStreamsManager { } void sortStreams({List? streams, YTVideosSorting? sort, bool? sortingByTop, bool jumpToZero = true}) { - sort ??= sorting.value; streams ??= streamsList; + if (streams == null) return; + sort ??= sorting.value; sortingByTop ??= this.sortingByTop.value; switch (sort) { case YTVideosSorting.date: - sortingByTop ? streams.sortByReverse((e) => e.date ?? DateTime(0)) : streams.sortBy((e) => e.date ?? DateTime(0)); + sortingByTop ? streams.sortByReverse((e) => e.publishedAt.date ?? DateTime(0)) : streams.sortBy((e) => e.publishedAt.date ?? DateTime(0)); break; case YTVideosSorting.views: - sortingByTop ? streams.sortByReverse((e) => e.viewCount ?? 0) : streams.sortBy((e) => e.viewCount ?? 0); + sortingByTop ? streams.sortByReverse((e) => e.viewsCount ?? 0) : streams.sortBy((e) => e.viewsCount ?? 0); break; case YTVideosSorting.duration: - sortingByTop ? streams.sortByReverse((e) => e.duration ?? Duration.zero) : streams.sortBy((e) => e.duration ?? Duration.zero); + sortingByTop ? streams.sortByReverse((e) => e.durSeconds ?? 0) : streams.sortBy((e) => e.durSeconds ?? 0); break; default: @@ -114,7 +118,16 @@ mixin YoutubeStreamsManager { sorting.value = sort; this.sortingByTop.value = sortingByTop; - if (jumpToZero && scrollController.hasClients) scrollController.jumpTo(0); + if (jumpToZero && scrollController.hasClients) { + final scrolledFar = scrollController.offset > context.height * 0.7; + if (scrolledFar) { + scrollController.animateToEff( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.fastEaseInToSlowEaseOut, + ); + } + } } (String, IconData) sortToTextAndIcon(YTVideosSorting sort) { diff --git a/lib/controller/backup_controller.dart b/lib/controller/backup_controller.dart index 3b471a1b..7db936d5 100644 --- a/lib/controller/backup_controller.dart +++ b/lib/controller/backup_controller.dart @@ -16,8 +16,8 @@ import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/main.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; class BackupController { @@ -292,6 +292,6 @@ class BackupController { YoutubePlaylistController.inst.prepareAllPlaylists(); YoutubeHistoryController.inst.prepareHistoryFile(); await YoutubePlaylistController.inst.prepareDefaultPlaylistsFile(); - YoutubeController.inst.fillBackupInfoMap(); // for history videos info. + YoutubeInfoController.utils.fillBackupInfoMap(); // for history videos info. } } diff --git a/lib/controller/json_to_history_parser.dart b/lib/controller/json_to_history_parser.dart index 05d2c1fb..369928e7 100644 --- a/lib/controller/json_to_history_parser.dart +++ b/lib/controller/json_to_history_parser.dart @@ -24,8 +24,8 @@ import 'package:namida/core/utils.dart'; import 'package:namida/ui/dialogs/track_advanced_dialog.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; class JsonToHistoryParser { static JsonToHistoryParser get inst => _instance; @@ -529,7 +529,7 @@ class JsonToHistoryParser { printy('updatedIds: ${updatedIds.length}'); }, ); - YoutubeController.inst.fillBackupInfoMap(); + YoutubeInfoController.utils.fillBackupInfoMap(); } HistoryController.inst.historyMap.value = res.localHistory; diff --git a/lib/controller/logs_controller.dart b/lib/controller/logs_controller.dart index 6a23d515..9aa2cc82 100644 --- a/lib/controller/logs_controller.dart +++ b/lib/controller/logs_controller.dart @@ -10,13 +10,13 @@ import 'package:namida/core/extensions.dart'; final logger = _Log(); class _Log { - late Logger _logger = updateLogger(Level.all); + late Logger logger = updateLogger(Level.all); void updateLoggerPath() => updateLogger(Level.all); Logger updateLogger(Level? level) { final filter = kDebugMode ? DevelopmentFilter() : ProductionFilter(); - return _logger = Logger( + return logger = Logger( level: level, filter: filter, printer: PrettyPrinter( @@ -35,7 +35,7 @@ class _Log { StackTrace? st, }) { printo('$e => $message\n=> $st', isError: true); - _logger.e(message, error: e, stackTrace: st); + logger.e(message, error: e, stackTrace: st); } } diff --git a/lib/controller/lyrics_search_utils/lrc_search_utils_base.dart b/lib/controller/lyrics_search_utils/lrc_search_utils_base.dart index 7ce63696..6e6ae6b6 100644 --- a/lib/controller/lyrics_search_utils/lrc_search_utils_base.dart +++ b/lib/controller/lyrics_search_utils/lrc_search_utils_base.dart @@ -7,7 +7,7 @@ import 'package:namida/controller/lyrics_search_utils/lrc_search_details.dart'; import 'package:namida/controller/lyrics_search_utils/lrc_search_utils_selectable.dart'; import 'package:namida/controller/lyrics_search_utils/lrc_search_utils_youtubeid.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; abstract class LrcSearchUtils { const LrcSearchUtils(); @@ -17,8 +17,8 @@ abstract class LrcSearchUtils { final tr = item.track; return LrcSearchUtilsSelectable(tr.toTrackExt(), tr); } else if (item is YoutubeID) { - final videoInfo = YoutubeController.inst.getVideoInfo(item.id, checkFromStorage: true); - return LrcSearchUtilsYoutubeID(item, videoInfo?.name); + final title = YoutubeInfoController.utils.getVideoName(item.id); + return LrcSearchUtilsYoutubeID(item, title); } return null; } diff --git a/lib/controller/player_controller.dart b/lib/controller/player_controller.dart index e33bd0eb..608ada93 100644 --- a/lib/controller/player_controller.dart +++ b/lib/controller/player_controller.dart @@ -5,18 +5,19 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:basic_audio_handler/basic_audio_handler.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:namida/core/utils.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:namida/base/audio_handler.dart'; import 'package:namida/class/audio_cache_detail.dart'; import 'package:namida/class/track.dart'; import 'package:namida/class/video.dart'; -import 'package:namida/base/audio_handler.dart'; -import 'package:namida/controller/settings.equalizer.dart'; -import 'package:namida/controller/namida_channel.dart'; import 'package:namida/controller/miniplayer_controller.dart'; +import 'package:namida/controller/namida_channel.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/queue_controller.dart'; +import 'package:namida/controller/settings.equalizer.dart'; import 'package:namida/controller/settings_controller.dart'; import 'package:namida/controller/video_controller.dart'; import 'package:namida/controller/wakelock_controller.dart'; @@ -25,8 +26,9 @@ import 'package:namida/core/extensions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/core/translations/language.dart'; +import 'package:namida/core/utils.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; class Player { static Player get inst => _instance; @@ -60,28 +62,27 @@ class Player { RxBaseCore> get currentQueue => _audioHandler.currentQueue; RxBaseCore get currentItem => _audioHandler.currentItem; - String get getCurrentVideoIdR => (YoutubeController.inst.currentYoutubeMetadataVideo.valueR ?? currentVideoInfo.valueR)?.id ?? currentVideoR?.id ?? ''; - String get getCurrentVideoId => (YoutubeController.inst.currentYoutubeMetadataVideo.value ?? currentVideoInfo.value)?.id ?? currentVideo?.id ?? ''; - RxBaseCore get videoPlayerInfo => _audioHandler.videoPlayerInfo; AndroidEqualizer get equalizer => _audioHandler.equalizer; AndroidLoudnessEnhancer get loudnessEnhancer => _audioHandler.loudnessEnhancer; int? get androidSessionId => _audioHandler.androidSessionId; - RxBaseCore get currentVideoInfo => _audioHandler.currentVideoInfo; - RxBaseCore get currentChannelInfo => _audioHandler.currentChannelInfo; - RxBaseCore get currentVideoStream => _audioHandler.currentVideoStream; - RxBaseCore get currentAudioStream => _audioHandler.currentAudioStream; + // RxBaseCore get currentVideoInfo => _audioHandler.currentVideoInfo; + // RxBaseCore get currentChannelInfo => _audioHandler.currentChannelInfo; + RxBaseCore get currentVideoStream => _audioHandler.currentVideoStream; + RxBaseCore get currentAudioStream => _audioHandler.currentAudioStream; RxBaseCore get currentCachedVideo => _audioHandler.currentCachedVideo; RxBaseCore get currentCachedAudio => _audioHandler.currentCachedAudio; Duration get getCurrentVideoDurationR { Duration? playerDuration = currentItemDuration.valueR; if (playerDuration == null || playerDuration == Duration.zero) { - playerDuration = currentAudioStream.valueR?.durationMS?.milliseconds ?? - currentVideoStream.valueR?.durationMS?.milliseconds ?? - (currentVideo == null ? VideoController.inst.currentVideo.valueR?.durationMS.milliseconds : YoutubeController.inst.currentYoutubeMetadataVideo.valueR?.duration) ?? + playerDuration = currentAudioStream.valueR?.duration ?? + currentVideoStream.valueR?.duration ?? + (currentVideo == null + ? VideoController.inst.currentVideo.valueR?.durationMS.milliseconds + : YoutubeInfoController.current.currentYTStreams.valueR?.videoStreams.firstOrNull?.duration) ?? Duration.zero; } return playerDuration; @@ -90,9 +91,11 @@ class Player { Duration get getCurrentVideoDuration { Duration? playerDuration = Player.inst.currentItemDuration.value; if (playerDuration == null || playerDuration == Duration.zero) { - playerDuration = currentAudioStream.value?.durationMS?.milliseconds ?? - currentVideoStream.value?.durationMS?.milliseconds ?? - (currentVideo == null ? VideoController.inst.currentVideo.value?.durationMS.milliseconds : YoutubeController.inst.currentYoutubeMetadataVideo.value?.duration) ?? + playerDuration = currentAudioStream.value?.duration ?? + currentVideoStream.value?.duration ?? + (currentVideo == null + ? VideoController.inst.currentVideo.value?.durationMS.milliseconds // + : YoutubeInfoController.current.currentYTStreams.valueR?.videoStreams.firstOrNull?.duration) ?? Duration.zero; } return playerDuration; @@ -444,6 +447,7 @@ class Player { } Future onItemPlayYoutubeIDSetQuality({ + required VideoStreamsResult? mainStreams, required VideoStream? stream, required File? cachedFile, required bool useCache, @@ -451,6 +455,7 @@ class Player { NamidaVideo? videoItem, }) async { await _audioHandler.onItemPlayYoutubeIDSetQuality( + mainStreams: mainStreams, stream: stream, cachedFile: cachedFile, useCache: useCache, @@ -460,12 +465,14 @@ class Player { } Future onItemPlayYoutubeIDSetAudio({ - required AudioOnlyStream? stream, + required VideoStreamsResult? mainStreams, + required AudioStream? stream, required File? cachedFile, bool useCache = true, required String videoId, }) async { await _audioHandler.onItemPlayYoutubeIDSetAudio( + mainStreams: mainStreams, stream: stream, cachedFile: cachedFile, useCache: useCache, diff --git a/lib/controller/playlist_controller.dart b/lib/controller/playlist_controller.dart index 633ea524..32d6a808 100644 --- a/lib/controller/playlist_controller.dart +++ b/lib/controller/playlist_controller.dart @@ -191,8 +191,8 @@ class PlaylistController extends PlaylistManager { return action.value; } - Future favouriteButtonOnPressed(Track track) async { - return await super.toggleTrackFavourite( + bool favouriteButtonOnPressed(Track track) { + return super.toggleTrackFavourite( newTrack: TrackWithDate(dateAdded: currentTimeMS, track: track, source: TrackSource.local), identifyBy: (tr) => tr.track == track, ); diff --git a/lib/controller/thumbnail_manager.dart b/lib/controller/thumbnail_manager.dart index 23847aea..a9215866 100644 --- a/lib/controller/thumbnail_manager.dart +++ b/lib/controller/thumbnail_manager.dart @@ -5,9 +5,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:flutter/material.dart'; -import 'package:html/parser.dart' as parser; -import 'package:http/http.dart' as http; -import 'package:newpipeextractor_dart/utils/httpClient.dart'; +import 'package:youtipie/class/thumbnail.dart'; import 'package:namida/base/ports_provider.dart'; import 'package:namida/class/http_manager.dart'; @@ -15,7 +13,6 @@ import 'package:namida/class/http_response_wrapper.dart'; import 'package:namida/controller/ffmpeg_controller.dart'; import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; -import 'package:namida/youtube/class/yt_thumbnail_wrapper.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; class ThumbnailManager { @@ -38,6 +35,7 @@ class ThumbnailManager { File? imageUrlToCacheFile({ required String? id, required String? url, + String? symlinkId, bool isTemp = false, }) { String? finalUrl = url; @@ -51,38 +49,14 @@ class ThumbnailManager { final dirPrefix = isTemp ? 'temp/' : ''; final file = id != null && id != '' - ? File("${AppDirs.YT_THUMBNAILS}$dirPrefix$id.png") + ? File("${AppDirs.YT_THUMBNAILS}$dirPrefix${symlinkId ?? '$id.png'}") : finalUrl == null ? null - : File("${AppDirs.YT_THUMBNAILS_CHANNELS}$dirPrefix$finalUrl"); + : File("${AppDirs.YT_THUMBNAILS_CHANNELS}$dirPrefix${symlinkId ?? finalUrl}"); return file; } - static Future _getChannelAvatarUrlIsolate(String channelId) async { - final url = 'https://www.youtube.com/channel/$channelId?hl=en'; - final client = http.Client(); - try { - final response = await client.get(Uri.parse(url), headers: ExtractorHttpClient.defaultHeaders); - final raw = response.body; - final s = parser.parse(raw).querySelector('meta[property="og:image"]')?.attributes['content']; - return s; - } catch (_) { - return null; - } finally { - client.close(); - } - } - - File? getYoutubeThumbnailFromCacheSync({String? id, String? channelUrl, bool isTemp = false}) { - if (id == null && channelUrl == null) return null; - - final file = imageUrlToCacheFile(id: id, url: channelUrl, isTemp: isTemp); - - if (file != null && file.existsSync()) return file; - return null; - } - Future extractVideoThumbnailAndSave({ required String? videoPath, required bool isLocal, @@ -99,32 +73,45 @@ class ThumbnailManager { return fileExists ? file : null; } + File? getYoutubeThumbnailFromCacheSync({String? id, String? customUrl, bool isTemp = false}) { + if (id == null && customUrl == null) return null; + final file = imageUrlToCacheFile(id: id, url: customUrl, isTemp: isTemp); + if (file != null && file.existsSync()) return file; + return null; + } + Future getYoutubeThumbnailAndCache({ String? id, - String? channelUrlOrID, + String? customUrl, bool isImportantInCache = true, - bool hqChannelImage = false, + String? symlinkId, VoidCallback? onNotFound, }) async { - if (id == null && channelUrlOrID == null) return null; + if (id == null && customUrl == null) return null; final isTemp = isImportantInCache ? false : true; - final file = imageUrlToCacheFile(id: id, url: channelUrlOrID, isTemp: isTemp); + final file = imageUrlToCacheFile(id: id, url: customUrl, isTemp: isTemp); if (file == null) return null; - - if (channelUrlOrID != null && hqChannelImage) { - final res = await _getChannelAvatarUrlIsolate.thready(channelUrlOrID); - if (res != null) channelUrlOrID = res; + if (file.existsSync()) return file; + + if (symlinkId != null) { + final symlinkfile = imageUrlToCacheFile(id: id, url: customUrl, symlinkId: symlinkId, isTemp: isTemp); + if (symlinkfile != null && symlinkfile.existsSync()) { + final targetFilePath = Link.fromUri(symlinkfile.uri).targetSync(); + final targetFile = File(targetFilePath); + if (targetFile.existsSync()) return targetFile; + } } final itemId = file.path.getFilenameWOExt; final downloaded = await _getYoutubeThumbnail( itemId: itemId, - urls: channelUrlOrID == null ? null : [channelUrlOrID], + urls: customUrl == null ? null : [customUrl], isVideo: id != null, isImportantInCache: isImportantInCache, destinationFile: file, + symlinkId: symlinkId, isTemp: isTemp, forceRequest: false, lowerResYTID: false, @@ -134,7 +121,7 @@ class ThumbnailManager { return downloaded; } - Future getLowResYoutubeVideoThumbnail(String? videoId, {bool useHighQualityIfEnoughListens = true, VoidCallback? onNotFound}) async { + Future getLowResYoutubeVideoThumbnail(String? videoId, {String? symlinkId, bool useHighQualityIfEnoughListens = true, VoidCallback? onNotFound}) async { if (videoId == null) return null; bool isTemp = true; @@ -151,6 +138,7 @@ class ThumbnailManager { isVideo: true, isImportantInCache: false, destinationFile: file, + symlinkId: symlinkId, isTemp: isTemp, forceRequest: false, lowerResYTID: lowerResYTID, @@ -173,11 +161,12 @@ class ThumbnailManager { required bool forceRequest, required bool isImportantInCache, required File destinationFile, + required String? symlinkId, required VoidCallback? onNotFound, }) async { final links = []; if (isVideo && (urls == null || urls.isEmpty)) { - final yth = YTThumbnail(itemId); + final yth = YoutiPieVideoThumbnail(itemId); if (lowerResYTID) { links.addAll(yth.allQualitiesExceptHighest); } else { @@ -193,6 +182,7 @@ class ThumbnailManager { forceRequest: forceRequest, isImportantInCache: isImportantInCache, destinationFile: destinationFile, + symlinkId: symlinkId, isTemp: isTemp, onNotFound: onNotFound, ); @@ -212,6 +202,7 @@ class _YTThumbnailDownloadManager with PortsProvider { required bool isTemp, required bool isImportantInCache, required File destinationFile, + required String? symlinkId, required VoidCallback? onNotFound, }) async { if (_notFoundThumbnails[id] == true) { @@ -234,6 +225,7 @@ class _YTThumbnailDownloadManager with PortsProvider { 'isImportantInCache': isImportantInCache, 'isTemp': isTemp, 'destinationFile': destinationFile, + 'symlinkId': symlinkId, }; await initialize(); await sendPort(p); @@ -286,6 +278,7 @@ class _YTThumbnailDownloadManager with PortsProvider { final isImportantInCache = p['isImportantInCache'] as bool? ?? false; final isTemp = p['isTemp'] as bool? ?? false; final destinationFile = p['destinationFile'] as File; + final symlinkId = p['symlinkId'] as String?; if (forceRequest == true && destinationFile.existsSync()) { final res = _YTThumbnailDownloadResult( @@ -339,6 +332,9 @@ class _YTThumbnailDownloadManager with PortsProvider { final downloadStream = response.asBroadcastStream(); await fileStream.addStream(downloadStream); newFile = destinationFileTemp.renameSync(destinationFile.path); // rename .temp + if (symlinkId != null) { + Link.fromUri(Uri.file("${newFile.parent.path}/symlinkId")).create(newFile.path).catchError((_) => Link('')); + } if (deleteOldExtracted) { File("${destinationFile.parent}/EXT_${destinationFile.path.getFilename}").delete().catchError((_) => File('')); } diff --git a/lib/controller/video_controller.dart b/lib/controller/video_controller.dart index 8c850bfc..0e156789 100644 --- a/lib/controller/video_controller.dart +++ b/lib/controller/video_controller.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:namida/core/utils.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; import 'package:namida/class/media_info.dart'; import 'package:namida/class/track.dart'; @@ -19,9 +19,11 @@ import 'package:namida/core/constants.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/functions.dart'; +import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/video_widget.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; class NamidaVideoWidget extends StatelessWidget { final bool enableControls; @@ -123,9 +125,9 @@ class VideoController { void updateShouldShowControls(double animationValue) { final isExpanded = animationValue >= 0.95; if (isExpanded) { - // YoutubeController.inst.startDimTimer(); // bad experience honestly + // YoutubeMiniplayerUiController.inst.startDimTimer(); // bad experience honestly } else { - // YoutubeController.inst.cancelDimTimer(); + // YoutubeMiniplayerUiController.inst.cancelDimTimer(); videoControlsKey.currentState?.setControlsVisibily(false); } } @@ -158,8 +160,8 @@ class VideoController { final localVideoExtractTotal = 0.obs; final currentVideo = Rxn(); - final currentPossibleVideos = [].obs; - final currentYTQualities = [].obs; + final currentPossibleLocalVideos = [].obs; + final currentYTStreams = Rxn(); final currentDownloadedBytes = Rxn(); /// Indicates that [updateCurrentVideo] didn't find any matching video. @@ -233,12 +235,12 @@ class VideoController { isNoVideosAvailable.value = false; currentDownloadedBytes.value = null; currentVideo.value = null; - currentYTQualities.clear(); + currentYTStreams.value = null; if (track == null || track == kDummyTrack) return null; if (!settings.enableVideoPlayback.value) return null; final possibleVideos = await _getPossibleVideosFromTrack(track); - currentPossibleVideos.value = possibleVideos; + currentPossibleLocalVideos.value = possibleVideos; final trackYTID = track.youtubeID; if (possibleVideos.isEmpty && trackYTID == '') isNoVideosAvailable.value = true; @@ -347,23 +349,24 @@ class VideoController { } Future fetchYTQualities(Track track) async { - final available = await YoutubeController.inst.getAvailableVideoStreamsOnly(track.youtubeID); - if (_canExecuteForCurrentTrackOnly(track)) currentYTQualities.assignAll(available); + final streamsResult = await YoutubeInfoController.video.fetchVideoStreams(track.youtubeID, forceRequest: false); + if (_canExecuteForCurrentTrackOnly(track)) currentYTStreams.value = streamsResult; } Future getVideoFromYoutubeAndUpdate( String? id, { + VideoStreamsResult? mainStreams, VideoStream? stream, }) async { final tr = Player.inst.currentTrack?.track; if (tr == null) return null; - final dv = await fetchVideoFromYoutube(id, stream: stream); + final dv = await fetchVideoFromYoutube(id, stream: stream, mainStreams: mainStreams); if (!settings.enableVideoPlayback.value) return null; if (_canExecuteForCurrentTrackOnly(tr)) { currentVideo.value = dv; - currentYTQualities.refresh(); - if (dv != null) currentPossibleVideos.addNoDuplicates(dv); - currentPossibleVideos.sortByReverseAlt( + currentYTStreams.refresh(); + if (dv != null) currentPossibleLocalVideos.addNoDuplicates(dv); + currentPossibleLocalVideos.sortByReverseAlt( (e) { if (e.resolution != 0) return e.resolution; if (e.height != 0) return e.height; @@ -377,6 +380,7 @@ class VideoController { Future fetchVideoFromYoutube( String? id, { + VideoStreamsResult? mainStreams, VideoStream? stream, }) async { _downloadTimerCancel(); @@ -395,23 +399,42 @@ class VideoController { _downloadTimer = Timer.periodic(const Duration(seconds: 1), (_) => updateCurrentBytes()); + VideoStream? streamToUse = stream; + if (stream == null || mainStreams?.hasExpired() != false) { + // expired null or true + mainStreams = await YoutubeInfoController.video.fetchVideoStreams(id); + if (mainStreams != null) { + final newStreamToUse = mainStreams.videoStreams.firstWhereEff((e) => e.itag == stream?.itag) ?? YoutubeController.inst.getPreferredStreamQuality(mainStreams.videoStreams); + streamToUse = newStreamToUse; + } + } + + if (streamToUse == null) { + if (_canExecuteForCurrentTrackOnly(initialTrack)) { + currentDownloadedBytes.value = null; + _downloadTimerCancel(); + } + return null; + } + final downloadedVideo = await YoutubeController.inst.downloadYoutubeVideo( canStartDownloading: () => settings.enableVideoPlayback.value, id: id, - stream: stream, + stream: streamToUse, + creationDate: mainStreams?.info?.uploadDate.date ?? mainStreams?.info?.publishDate.date, onAvailableQualities: (availableStreams) {}, onChoosingQuality: (choosenStream) { if (_canExecuteForCurrentTrackOnly(initialTrack)) { currentVideo.value = NamidaVideo( path: '', ytID: id, - height: choosenStream.height ?? 0, - width: choosenStream.width ?? 0, - sizeInBytes: choosenStream.sizeInBytes ?? 0, - frameratePrecise: choosenStream.fps?.toDouble() ?? 0.0, + height: choosenStream.height, + width: choosenStream.width, + sizeInBytes: choosenStream.sizeInBytes, + frameratePrecise: choosenStream.fps.toDouble(), creationTimeMS: 0, - durationMS: choosenStream.durationMS ?? 0, - bitrate: choosenStream.bitrate ?? 0, + durationMS: choosenStream.duration.inMilliseconds, + bitrate: choosenStream.bitrate, ); } }, @@ -430,8 +453,10 @@ class VideoController { _videoCacheIDMap.addNoDuplicatesForce(downloadedVideo.ytID ?? '', downloadedVideo); await _saveCachedVideosFile(); } - currentDownloadedBytes.value = null; - _downloadTimerCancel(); + if (_canExecuteForCurrentTrackOnly(initialTrack)) { + currentDownloadedBytes.value = null; + _downloadTimerCancel(); + } return downloadedVideo; } diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 200f6248..a071d61c 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -141,6 +141,10 @@ class NamidaLinkUtils { } return didLaunch; } + + static String? extractPlaylistId(String playlistUrl) { + return NamidaLinkRegex.youtubePlaylistsLinkRegex.firstMatch(playlistUrl)?.group(1); + } } /// Files used by Namida @@ -212,14 +216,19 @@ class AppDirs { // ================= Youtube ================= static final YOUTUBE_MAIN_DIRECTORY = '$USER_DATA/Youtube'; + static final YOUTIPIE_CACHE = '$YOUTUBE_MAIN_DIRECTORY/Youtipie/'; + static final YOUTIPIE_DATA = '$YOUTUBE_MAIN_DIRECTORY/Youtipie_data/'; + static final YT_PLAYLISTS = '$YOUTUBE_MAIN_DIRECTORY/Youtube Playlists/'; static final YT_HISTORY_PLAYLIST = '$YOUTUBE_MAIN_DIRECTORY/Youtube History/'; static final YT_THUMBNAILS = '$YOUTUBE_MAIN_DIRECTORY/YTThumbnails/'; static final YT_THUMBNAILS_CHANNELS = '$YOUTUBE_MAIN_DIRECTORY/YTThumbnails Channels/'; + static final YT_METADATA = '$YOUTUBE_MAIN_DIRECTORY/Metadata Videos/'; static final YT_METADATA_TEMP = '$YOUTUBE_MAIN_DIRECTORY/Metadata Videos Temp/'; static final YT_METADATA_CHANNELS = '$YOUTUBE_MAIN_DIRECTORY/Metadata Channels/'; static final YT_METADATA_COMMENTS = '$YOUTUBE_MAIN_DIRECTORY/Metadata Comments/'; + static final YT_STATS = '$YOUTUBE_MAIN_DIRECTORY/Youtube Stats/'; static final YT_PALETTES = '$YOUTUBE_MAIN_DIRECTORY/Palettes/'; static final YT_DOWNLOAD_TASKS = '$YOUTUBE_MAIN_DIRECTORY/Download Tasks/'; diff --git a/lib/core/functions.dart b/lib/core/functions.dart index 1dbb4bc3..7d02a5a0 100644 --- a/lib/core/functions.dart +++ b/lib/core/functions.dart @@ -37,8 +37,8 @@ import 'package:namida/ui/pages/subpages/playlist_tracks_subpage.dart'; import 'package:namida/ui/pages/subpages/queue_tracks_subpage.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_info_controller.dart'; import 'package:namida/youtube/controller/yt_generators_controller.dart'; class NamidaOnTaps { @@ -94,12 +94,10 @@ class NamidaOnTaps { } Future onHistoryPlaylistTap({ + HistoryScrollInfo? scrollInfo, double initialScrollOffset = 0, - int? indexToHighlight, - int? dayOfHighLight, }) async { - HistoryController.inst.indexToHighlight.value = indexToHighlight; - HistoryController.inst.dayOfHighLight.value = dayOfHighLight; + HistoryController.inst.highlightedItem.value = scrollInfo; void jump() { if (HistoryController.inst.scrollController.hasClients) { @@ -818,6 +816,7 @@ class TracksAddOnTap { if (currentTrackS is! Selectable) return; final currentTrack = currentTrackS.track; showAddItemsToQueueDialog( + onDisposing: null, context: context, tiles: (getAddTracksTile) { return [ @@ -1153,10 +1152,15 @@ class TracksAddOnTap { final currentVideo = Player.inst.currentVideo; if (currentVideo == null) return; final currentVideoId = currentVideo.id; - final currentVideoName = YoutubeController.inst.getVideoName(currentVideoId) ?? currentVideoId; + final currentVideoName = YoutubeInfoController.utils.getVideoName(currentVideoId) ?? currentVideoId; + + final isLoadingVideoDate = false.obs; NamidaYTGenerator.inst.initialize(); showAddItemsToQueueDialog( + onDisposing: () { + isLoadingVideoDate.close(); + }, context: context, tiles: (getAddTracksTile) { return [ @@ -1214,7 +1218,7 @@ class TracksAddOnTap { const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), Obx( () { - final isLoading = NamidaYTGenerator.inst.didPrepareResources.valueR == false; + final isLoading = isLoadingVideoDate.valueR || NamidaYTGenerator.inst.didPrepareResources.valueR == false; return AnimatedEnabled( enabled: !isLoading, child: getAddTracksTile( @@ -1226,7 +1230,18 @@ class TracksAddOnTap { icon: Broken.calendar_1, insertionType: QueueInsertionType.sameReleaseDate, onTap: (insertionType) async { - final videos = await NamidaYTGenerator.inst.generateVideoFromSameEra(currentVideoId, videoToRemove: currentVideoId); + DateTime? date = YoutubeInfoController.utils.getVideoReleaseDate(currentVideoId); + if (date == null) { + isLoadingVideoDate.value = true; + final info = await YoutubeInfoController.video.fetchVideoStreams(currentVideoId, forceRequest: false); + date = info?.info?.publishedAt.date ?? info?.info?.publishDate.date; + isLoadingVideoDate.value = false; + } + if (date == null) { + snackyy(message: 'failed to fetch video date', isError: true, title: lang.ERROR); + return; + } + final videos = await NamidaYTGenerator.inst.generateVideoFromSameEra(currentVideoId, date, videoToRemove: currentVideoId); Player.inst .addToQueue( videos, @@ -1268,6 +1283,7 @@ class TracksAddOnTap { Future showAddItemsToQueueDialog({ required BuildContext context, + required void Function()? onDisposing, required List Function( Widget Function({ required String title, @@ -1414,6 +1430,10 @@ class TracksAddOnTap { } await NamidaNavigator.inst.navigateDialog( + onDisposing: () { + onDisposing?.call(); + shouldShowConfigureIcon.close(); + }, dialog: CustomBlurryDialog( normalTitleStyle: true, title: lang.NEW_TRACKS_ADD, diff --git a/lib/core/namida_converter_ext.dart b/lib/core/namida_converter_ext.dart index 480ebeed..7dd393a2 100644 --- a/lib/core/namida_converter_ext.dart +++ b/lib/core/namida_converter_ext.dart @@ -1,11 +1,13 @@ - import 'dart:io'; import 'package:flutter/material.dart'; import 'package:history_manager/history_manager.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:path/path.dart' as p; import 'package:playlist_manager/module/playlist_id.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/class/faudiomodel.dart'; import 'package:namida/class/folder.dart'; @@ -50,7 +52,6 @@ import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings_search_bar.dart'; import 'package:namida/ui/widgets/stats.dart'; import 'package:namida/youtube/class/youtube_id.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' as ytplc; import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; @@ -175,16 +176,26 @@ extension YTVideoQuality on String { } } -extension CacheGetterAudio on AudioOnlyStream { +extension CacheGetterAudio on AudioStream { String cacheKey(String id) { final audio = this; // -- wont save english track, only saves non-english ones. - final langCode = audio.language?.toLowerCase(); - final langName = audio.displayLanguage?.toLowerCase(); - final isNull = langCode == null || langName == null; - final isEnglish = langCode == 'en' || langName == 'english'; - final languageText = isNull || isEnglish ? '' : '_${audio.language}_${audio.displayLanguage}'; - return "$id${languageText}_${audio.bitrate}.${audio.formatSuffix}"; + String languageText = ''; + + final audioTrack = audio.audioTrack; + if (audioTrack != null) { + final langCode = audioTrack.langCode?.toLowerCase(); + final langName = audioTrack.displayName?.toLowerCase(); + + if (langCode == 'en' && audioTrack.isDefault == true) { + // -- is original english + // -- isDefault check is required cuz there can be more than 1 english audio + } else { + languageText = '_${langCode}_$langName'; + } + } + + return "$id${languageText}_${audio.bitrate}.${audio.codecInfo.container}"; } String cachePath(String id, {String? directory}) { @@ -198,10 +209,10 @@ extension CacheGetterAudio on AudioOnlyStream { } } -extension CacheGetterVideo on VideoOnlyStream { +extension CacheGetterVideo on VideoStream { String cacheKey(String id, {String? directory}) { final video = this; - return "${id}_${video.resolution}.${video.formatSuffix}"; + return "${id}_${video.qualityLabel}.${video.codecInfo.container}"; } String cachePath(String id, {String? directory}) { @@ -215,34 +226,6 @@ extension CacheGetterVideo on VideoOnlyStream { } } -extension StreamInfoUtils on StreamInfoItem { - VideoInfo toVideoInfo() { - return VideoInfo( - id: id, - url: url, - name: name, - uploaderName: uploaderName, - uploaderUrl: uploaderUrl, - uploaderAvatarUrl: uploaderAvatarUrl, - date: date, - isDateApproximation: isDateApproximation, - description: null, - duration: duration, - viewCount: viewCount, - likeCount: null, - category: null, - ageLimit: null, - tags: null, - thumbnailUrl: thumbnailUrl, - isUploaderVerified: isUploaderVerified, - textualUploadDate: textualUploadDate, - uploaderSubscriberCount: -1, - privacy: null, - isShortFormContent: isShortFormContent, - ); - } -} - extension MediaInfoToFAudioModel on MediaInfo? { FAudioModel? toFAudioModel() { final infoFull = this; @@ -423,28 +406,28 @@ extension OnYoutubeLinkOpenActionUtils on OnYoutubeLinkOpenAction { String toText() => _NamidaConverters.inst.getTitle(this); IconData toIcon() => _NamidaConverters.inst.getIcon(this); - Future executePlaylist(String playlistUrl, {YoutubePlaylist? playlist, required BuildContext? context}) async { - final plInfo = playlist ?? await YoutubeController.inst.getPlaylistInfo(playlistUrl); + Future executePlaylist({required String playlistId, YoutiPiePlaylistResult? playlist}) async { + final plInfo = playlist ?? await YoutiPie.playlist.fetchPlaylist(playlistId: playlistId); if (plInfo == null) { snackyy(title: lang.ERROR, message: 'error retrieving playlist info, check your connection?'); return; } - final didFetch = await plInfo.fetchAllPlaylistStreams(context: context?.mounted == true ? context : null); + final didFetch = await plInfo.info.fetchAllPlaylistStreams(showProgressSheet: true, playlist: plInfo); if (!didFetch) { snackyy(title: lang.ERROR, message: 'error fetching playlist videos'); return; } - final streams = plInfo.streams; + final streams = plInfo.items; - final playlistId = playlist?.id == null ? null : PlaylistID(id: playlist!.id!); - Iterable getPlayables() => streams.map((e) => YoutubeID(id: e.id ?? '', playlistID: playlistId)); + final plID = PlaylistID(id: playlistId); + Iterable getPlayables() => streams.map((e) => YoutubeID(id: e.id, playlistID: plID)); switch (this) { case OnYoutubeLinkOpenAction.showDownload: - plInfo.showPlaylistDownloadSheet(context: context?.mounted == true ? context : null); + plInfo.info.showPlaylistDownloadSheet(showProgressSheet: true, playlistToFetch: plInfo); case OnYoutubeLinkOpenAction.addToPlaylist: - showAddToPlaylistSheet(ids: streams.map((e) => e.id ?? ''), idsNamesLookup: {}); + showAddToPlaylistSheet(ids: streams.map((e) => e.id), idsNamesLookup: {}); case OnYoutubeLinkOpenAction.play: await Player.inst.playOrPause(0, getPlayables(), QueueSource.others); case OnYoutubeLinkOpenAction.playNext: @@ -455,7 +438,7 @@ extension OnYoutubeLinkOpenActionUtils on OnYoutubeLinkOpenAction { await Player.inst.addToQueue(getPlayables(), insertAfterLatest: true); case OnYoutubeLinkOpenAction.alwaysAsk: _showAskDialog( - (action) => action.executePlaylist(playlistUrl, context: context, playlist: plInfo), + (action) => action.executePlaylist(playlistId: playlistId, playlist: plInfo), playlistToOpen: plInfo, playlistToAddAs: plInfo, ); @@ -498,8 +481,8 @@ extension OnYoutubeLinkOpenActionUtils on OnYoutubeLinkOpenAction { } } - void _showAskDialog(void Function(OnYoutubeLinkOpenAction action) onTap, {YoutubePlaylist? playlistToOpen, YoutubePlaylist? playlistToAddAs}) { - String playlistNameToAddAs = playlistToAddAs?.name ?? ''; + void _showAskDialog(void Function(OnYoutubeLinkOpenAction action) onTap, {YoutiPiePlaylistResult? playlistToOpen, YoutiPiePlaylistResult? playlistToAddAs}) { + String playlistNameToAddAs = playlistToAddAs?.info.title ?? ''; String suffix = ''; int suffixIndex = 1; while (ytplc.YoutubePlaylistController.inst.playlistsMap["$playlistNameToAddAs$suffix"] != null) { @@ -579,7 +562,7 @@ extension OnYoutubeLinkOpenActionUtils on OnYoutubeLinkOpenAction { didAddToPlaylist.value = true; ytplc.YoutubePlaylistController.inst.addNewPlaylist( playlistNameToAddAs, - videoIds: playlistToAddAs?.streams.map((e) => e.id ?? '') ?? [], + videoIds: playlistToAddAs?.items.map((e) => e.id), ); }, ), @@ -595,10 +578,11 @@ extension PerformanceModeUtils on PerformanceMode { String toText() => _NamidaConverters.inst.getTitle(this); IconData toIcon() => _NamidaConverters.inst.getIcon(this); - Future execute() async { + Future executeAndSave() async { switch (this) { case PerformanceMode.highPerformance: settings.save( + performanceMode: PerformanceMode.highPerformance, enableBlurEffect: false, enableGlowEffect: false, enableMiniplayerParallaxEffect: false, @@ -606,6 +590,7 @@ extension PerformanceModeUtils on PerformanceMode { ); case PerformanceMode.balanced: settings.save( + performanceMode: PerformanceMode.balanced, enableBlurEffect: false, enableGlowEffect: false, enableMiniplayerParallaxEffect: true, @@ -613,12 +598,16 @@ extension PerformanceModeUtils on PerformanceMode { ); case PerformanceMode.goodLooking: settings.save( + performanceMode: PerformanceMode.goodLooking, enableBlurEffect: true, enableGlowEffect: true, enableMiniplayerParallaxEffect: true, artworkCacheHeightMultiplier: 1.0, ); - // case PerformanceMode.custom: + case PerformanceMode.custom: + settings.save( + performanceMode: PerformanceMode.custom, + ); default: null; } diff --git a/lib/core/translations/language.dart b/lib/core/translations/language.dart index be6d5e77..83316d58 100644 --- a/lib/core/translations/language.dart +++ b/lib/core/translations/language.dart @@ -1,3 +1,5 @@ +// ignore_for_file: non_constant_identifier_names + import 'dart:convert'; import 'package:flutter/services.dart'; @@ -11,9 +13,11 @@ import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/core/translations/keys.dart'; import 'package:namida/main.dart'; +part 'static_strings.dart'; + Language get lang => Language.inst; -class Language extends LanguageKeys { +class Language extends LanguageKeys with _StaticStrings { static Language get inst => _instance; static final Language _instance = Language._internal(); Language._internal(); diff --git a/lib/core/translations/static_strings.dart b/lib/core/translations/static_strings.dart new file mode 100644 index 00000000..18696951 --- /dev/null +++ b/lib/core/translations/static_strings.dart @@ -0,0 +1,8 @@ +// ignore_for_file: non_constant_identifier_names + +part of 'language.dart'; + +mixin _StaticStrings { + final NO_NETWORK_AVAILABLE_TO_FETCH_VIDEO_PAGE = 'No Network Available to fetch video page'; + final DID_YOU_MEAN = 'Did you mean'; +} diff --git a/lib/main.dart b/lib/main.dart index 6acc2f07..77c437ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; -import 'package:flutter_volume_controller/flutter_volume_controller.dart'; +import 'package:flutter_volume_controller/flutter_volume_controller.dart' show FlutterVolumeController; import 'package:jiffy/jiffy.dart'; import 'package:path_provider/path_provider.dart' as pp; import 'package:permission_handler/permission_handler.dart'; @@ -49,6 +49,7 @@ import 'package:namida/packages/scroll_physics_modified.dart'; import 'package:namida/ui/widgets/video_widget.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; @@ -141,6 +142,8 @@ void mainInitialization() async { const StorageCacheManager().trimExtraFiles(); + YoutubeInfoController.initialize(); + QueueController.inst.prepareAllQueuesFile(); await Player.inst.initializePlayer(); @@ -161,8 +164,7 @@ void mainInitialization() async { YoutubePlaylistController.inst.prepareAllPlaylists(); - YoutubeController.inst.loadInfoToMemory(); - YoutubeController.inst.fillBackupInfoMap(); // for history videos info. + YoutubeInfoController.utils.fillBackupInfoMap(); // for history videos info. await _initializeIntenties(); @@ -296,16 +298,16 @@ Future _initializeIntenties() async { final id = e.getYoutubeID; return id == '' ? null : id; }).whereType(); - final ytPlaylists = paths.map((e) { - final match = e.isEmpty ? null : NamidaLinkRegex.youtubePlaylistsLinkRegex.firstMatch(e)?[0]; - return match; + final ytPlaylistsIds = paths.map((e) { + final matchPlId = e.isEmpty ? null : NamidaLinkUtils.extractPlaylistId(e); + return matchPlId; }).whereType(); if (youtubeIds.isNotEmpty) { await _waitForFirstBuildContext.future; settings.onYoutubeLinkOpen.value.execute(youtubeIds); - } else if (ytPlaylists.isNotEmpty) { - for (final pl in ytPlaylists) { - await OnYoutubeLinkOpenAction.alwaysAsk.executePlaylist(pl, context: rootContext); + } else if (ytPlaylistsIds.isNotEmpty) { + for (final plid in ytPlaylistsIds) { + await OnYoutubeLinkOpenAction.alwaysAsk.executePlaylist(playlistId: plid); } } else { final existing = paths.where((element) => File(element).existsSync()); // this for sussy links diff --git a/lib/packages/miniplayer.dart b/lib/packages/miniplayer.dart index 3c6a9150..02432736 100644 --- a/lib/packages/miniplayer.dart +++ b/lib/packages/miniplayer.dart @@ -32,8 +32,9 @@ import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/library/track_tile.dart'; import 'package:namida/ui/widgets/settings/playback_settings.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; import 'package:namida/youtube/pages/yt_channel_subpage.dart'; import 'package:namida/youtube/widgets/yt_history_video_card.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; @@ -92,7 +93,7 @@ class _MiniPlayerParentState extends State with SingleTickerPr builder: (youtubeStyleMiniplayer) => AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: youtubeStyleMiniplayer - ? const YoutubeMiniPlayer(key: Key('yt_miniplayer')) // + ? YoutubeMiniPlayer(key: YoutubeMiniplayerUiController.inst.ytMiniplayerKey) // : const NamidaMiniPlayerYoutubeID(key: Key('local_miniplayer_yt')), ), ) @@ -136,7 +137,7 @@ class NamidaMiniPlayerTrack extends StatelessWidget { firstLine: firstLine, secondLine: secondLine, isLiked: track.isFavouriteR, - onLikeTap: (isLiked) async => await PlaylistController.inst.favouriteButtonOnPressed(track), + onLikeTap: (isLiked) async => PlaylistController.inst.favouriteButtonOnPressed(track), onMenuOpen: (_) => _openMenu(track), likedIcon: Broken.heart_tick, normalIcon: Broken.heart, @@ -261,12 +262,12 @@ class NamidaMiniPlayerTrack extends StatelessWidget { }, currentId: (item) => item.track.youtubeID, loadQualities: (item) async => await VideoController.inst.fetchYTQualities(item.track), - localVideos: VideoController.inst.currentPossibleVideos, - streamVideos: VideoController.inst.currentYTQualities, + localVideos: VideoController.inst.currentPossibleLocalVideos, + streams: VideoController.inst.currentYTStreams, onLocalVideoTap: (item, video) async { VideoController.inst.playVideoCurrent(video: video, track: item.track); }, - onStreamVideoTap: (item, videoId, stream, cacheFile) async { + onStreamVideoTap: (item, videoId, stream, cacheFile, streams) async { final cacheExists = cacheFile != null; if (!cacheExists) await VideoController.inst.getVideoFromYoutubeAndUpdate(videoId, stream: stream); VideoController.inst.playVideoCurrent( @@ -294,14 +295,15 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { const NamidaMiniPlayerYoutubeID({super.key}); void _openMenu(BuildContext context, YoutubeID video, TapUpDetails details) { - final info = YoutubeController.inst.getVideoInfo(video.id); + final vidpage = YoutubeInfoController.video.fetchVideoPageSync(video.id); + final vidstreams = YoutubeInfoController.video.fetchVideoStreamsSync(video.id); final popUpItems = NamidaPopupWrapper( childrenDefault: () => YTUtils.getVideoCardMenuItems( videoId: video.id, - url: info?.url, - channelUrl: info?.uploaderUrl, + url: vidpage?.videoInfo?.buildUrl() ?? vidstreams?.info?.buildUrl(), + channelID: vidpage?.channelInfo?.id, playlistID: null, - idsNamesLookup: {video.id: info?.name}, + idsNamesLookup: {video.id: vidpage?.videoInfo?.title ?? vidstreams?.info?.title}, playlistName: '', videoYTID: video, copyUrl: true, @@ -318,8 +320,8 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { String firstLine = ''; String secondLine = ''; - firstLine = YoutubeController.inst.getVideoName(video.id) ?? ''; - secondLine = YoutubeController.inst.getVideoChannelName(video.id) ?? ''; + firstLine = YoutubeInfoController.utils.getVideoName(video.id) ?? ''; + secondLine = YoutubeInfoController.utils.getVideoChannelName(video.id) ?? ''; if (firstLine == '') { firstLine = secondLine; secondLine = ''; @@ -360,6 +362,7 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { showMoreIcon: true, cardColorOpacity: 0.5, fadeOpacity: i < currentIndex ? 0.3 : 0.0, + canHaveDuplicates: true, ), key, ); @@ -368,14 +371,16 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { itemsKeyword: (number) => number.displayVideoKeyword, onAddItemsTap: (currentItem) => TracksAddOnTap().onAddVideosTap(context), topText: (currentItem) => - YoutubeController.inst.currentYoutubeMetadataChannel.value?.name ?? - Player.inst.currentChannelInfo.value?.name ?? - YoutubeController.inst.getVideoChannelName(currentItem.id) ?? + YoutubeInfoController.current.currentVideoPage.value?.channelInfo?.title ?? + YoutubeInfoController.current.currentYTStreams.value?.info?.channelName ?? + YoutubeInfoController.utils.getVideoChannelName(currentItem.id) ?? '', onTopTextTap: (currentItem) { - final channel = YoutubeController.inst.currentYoutubeMetadataChannel.value ?? Player.inst.currentChannelInfo.value; - final chid = channel?.id; - if (chid != null) NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid, channel: channel)); + final pageChannel = YoutubeInfoController.current.currentVideoPage.value?.channelInfo; + final channelId = pageChannel?.id ?? + YoutubeInfoController.current.currentYTStreams.value?.info?.channelId ?? // + YoutubeInfoController.utils.getVideoChannelID(currentItem.id); + if (channelId != null) NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: channelId, channel: pageChannel)); }, onMenuOpen: (currentItem, d) => _openMenu(context, currentItem, d), focusedMenuOptions: FocusedMenuOptions( @@ -395,14 +400,14 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { List? textChildren; if (settings.displayAudioInfoMiniplayer.valueR) { final audioStream = Player.inst.currentAudioStream.valueR; - final formatName = audioStream?.formatName; + final formatName = audioStream?.codecInfo.codec; final bitrate = audioStream?.bitrate ?? Player.inst.currentCachedAudio.valueR?.bitrate; final bitrateText = bitrate == null ? null : "${bitrate ~/ 1000} kps"; - final sampleRate = audioStream?.samplerate; + final sampleRate = audioStream?.codecInfo.embeddedAudioInfo?.audioSampleRate; final sampleRateText = sampleRate == null ? null : "$sampleRate khz"; - final language = audioStream?.language ?? Player.inst.currentCachedAudio.valueR?.langaugeCode; + final language = audioStream?.audioTrack?.langCode ?? Player.inst.currentCachedAudio.valueR?.langaugeCode; - final finalText = [ + final finalText = [ formatName, bitrateText, sampleRateText, @@ -435,7 +440,7 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { size = cached?.sizeInBytes; } final sizeFinal = size ?? 0; - final qualityText = stream?.resolution ?? (cached == null ? null : "${cached.resolution}p${cached.framerateText()}"); + final qualityText = stream?.qualityLabel ?? (cached == null ? null : "${cached.resolution}p${cached.framerateText()}"); return Text.rich( TextSpan( text: lang.VIDEO, @@ -476,19 +481,21 @@ class NamidaMiniPlayerYoutubeID extends StatelessWidget { }, currentId: (item) => item.id, loadQualities: null, - localVideos: YoutubeController.inst.currentCachedQualities, - streamVideos: YoutubeController.inst.currentYTQualities, + localVideos: YoutubeInfoController.current.currentCachedQualities, + streams: YoutubeInfoController.current.currentYTStreams, onLocalVideoTap: (item, video) async { Player.inst.onItemPlayYoutubeIDSetQuality( stream: null, + mainStreams: null, cachedFile: File(video.path), videoItem: video, useCache: true, videoId: Player.inst.currentVideo?.id ?? '', ); }, - onStreamVideoTap: (item, videoId, stream, cacheFile) async { + onStreamVideoTap: (item, videoId, stream, cacheFile, streams) async { Player.inst.onItemPlayYoutubeIDSetQuality( + mainStreams: streams, stream: stream, cachedFile: null, useCache: true, diff --git a/lib/packages/miniplayer_base.dart b/lib/packages/miniplayer_base.dart index b11a7880..019c2ffa 100644 --- a/lib/packages/miniplayer_base.dart +++ b/lib/packages/miniplayer_base.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:namida/controller/lyrics_controller.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/ui/dialogs/set_lrc_dialog.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:namida/class/track.dart'; import 'package:namida/class/video.dart'; @@ -33,6 +32,9 @@ import 'package:namida/ui/widgets/animated_widgets.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/ui/widgets/waveform.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:youtipie/core/extensions.dart'; class FocusedMenuOptions { final bool Function(E currentItem) onOpen; @@ -41,10 +43,10 @@ class FocusedMenuOptions { final Widget Function(E currentItem) builder; final RxList localVideos; final String? Function(E item) currentId; - final RxList streamVideos; + final Rxn streams; final Future Function(E item)? loadQualities; final Future Function(E item, NamidaVideo video) onLocalVideoTap; - final Future Function(E item, String? videoId, VideoOnlyStream stream, File? cacheFile) onStreamVideoTap; + final Future Function(E item, String? videoId, VideoStream stream, File? cacheFile, VideoStreamsResult? mainStreams) onStreamVideoTap; const FocusedMenuOptions({ required this.onOpen, @@ -53,7 +55,7 @@ class FocusedMenuOptions { required this.builder, required this.currentId, required this.localVideos, - required this.streamVideos, + required this.streams, required this.loadQualities, required this.onLocalVideoTap, required this.onStreamVideoTap, @@ -500,7 +502,7 @@ class _NamidaMiniPlayerBaseState extends State> { menuWidget: Obx( () { final availableVideos = widget.focusedMenuOptions.localVideos.valueR; - final ytVideos = widget.focusedMenuOptions.streamVideos.where((s) => s.formatSuffix != 'webm'); + final ytVideos = widget.focusedMenuOptions.streams.valueR?.videoStreams.withoutWebm(); return ListView( padding: const EdgeInsets.symmetric(vertical: 12.0), children: [ @@ -544,17 +546,17 @@ class _NamidaMiniPlayerBaseState extends State> { }, ), const NamidaContainerDivider(height: 2.0, margin: EdgeInsets.symmetric(vertical: 6.0)), - ...ytVideos.map( + ...?ytVideos?.map( (element) { final currentId = widget.focusedMenuOptions.currentId(currentItem); final cacheFile = currentId == null ? null : element.getCachedFile(currentId); final cacheExists = cacheFile != null; return _MPQualityButton( - onTap: () => widget.focusedMenuOptions.onStreamVideoTap(currentItem, currentId, element, cacheFile), + onTap: () => widget.focusedMenuOptions.onStreamVideoTap(currentItem, currentId, element, cacheFile, widget.focusedMenuOptions.streams.value), bgColor: cacheExists ? CurrentColor.inst.miniplayerColor.withAlpha(40) : null, icon: cacheExists ? Broken.tick_circle : Broken.import, - title: "${element.resolution} • ${element.sizeInBytes?.fileSizeFormatted}", - subtitle: "${element.formatSuffix} • ${element.bitrateText}", + title: "${element.qualityLabel} • ${element.sizeInBytes.fileSizeFormatted}", + subtitle: "${element.codecInfo.container} • ${element.bitrateText()}", ); }, ), diff --git a/lib/ui/dialogs/edit_tags_dialog.dart b/lib/ui/dialogs/edit_tags_dialog.dart index bef06ede..76a313d9 100644 --- a/lib/ui/dialogs/edit_tags_dialog.dart +++ b/lib/ui/dialogs/edit_tags_dialog.dart @@ -111,20 +111,18 @@ Future showSetYTLinkCommentDialog(List tracks, Color colorScheme) a searchText: searchText, onVideoTap: (video) { NamidaNavigator.inst.closeDialog(); - final url = video.url; - if (url != null) { - controller.text = url; - canEditComment.value = true; + final url = video.buildUrl(); + controller.text = url; + canEditComment.value = true; - snackyy( - message: 'Set to "${video.name ?? ''}" by "${video.uploaderName ?? ''}"', - top: false, - borderRadius: 0, - margin: EdgeInsets.zero, - leftBarIndicatorColor: colorScheme, - animationDurationMS: 500, - ); - } + snackyy( + message: 'Set to "${video.title}" by "${video.channelName ?? video.channel.title}"', + top: false, + borderRadius: 0, + margin: EdgeInsets.zero, + leftBarIndicatorColor: colorScheme, + animationDurationMS: 500, + ); }, ), ), diff --git a/lib/ui/pages/main_page.dart b/lib/ui/pages/main_page.dart index a6cc9b7b..a70f7b06 100644 --- a/lib/ui/pages/main_page.dart +++ b/lib/ui/pages/main_page.dart @@ -253,11 +253,13 @@ class NamidaSearchBar extends StatelessWidget { const NamidaSearchBar({super.key, required this.searchBarKey}); void _onSubmitted(String val) { - final ytPlaylistLink = NamidaLinkRegex.youtubePlaylistsLinkRegex.firstMatch(val)?[0]; - if (ytPlaylistLink != null && ytPlaylistLink != '') { - OnYoutubeLinkOpenAction.alwaysAsk.executePlaylist(ytPlaylistLink, context: rootContext); - return; - } + try { + final ytPlaylistId = NamidaLinkUtils.extractPlaylistId(val); + if (ytPlaylistId != null && ytPlaylistId != '') { + OnYoutubeLinkOpenAction.alwaysAsk.executePlaylist(playlistId: ytPlaylistId); + return; + } + } catch (_) {} final ytlink = NamidaLinkRegex.youtubeLinkRegex.firstMatch(val)?[0]; final ytID = ytlink?.getYoutubeID; diff --git a/lib/ui/pages/search_page.dart b/lib/ui/pages/search_page.dart index 1e1b9e10..4459612d 100644 --- a/lib/ui/pages/search_page.dart +++ b/lib/ui/pages/search_page.dart @@ -109,16 +109,11 @@ class SearchPage extends StatelessWidget { final playlistDimensions = Dimensions.inst.getArtistCardDimensions(Dimensions.playlistSearchGridCount); return BackgroundWrapper( child: NamidaTabView( - initialIndex: () { - switch (ScrollSearchController.inst.currentSearchType.value) { - case SearchType.localTracks: - return 0; - case SearchType.youtube: - return 1; - default: - return 0; - } - }(), + initialIndex: switch (ScrollSearchController.inst.currentSearchType.value) { + SearchType.localTracks => 0, + SearchType.youtube => 1, + SearchType.localVideos => 2, + }, onIndexChanged: (index) async { switch (index) { case 0: diff --git a/lib/ui/widgets/settings/advanced_settings.dart b/lib/ui/widgets/settings/advanced_settings.dart index 877b9c1d..ad294084 100644 --- a/lib/ui/widgets/settings/advanced_settings.dart +++ b/lib/ui/widgets/settings/advanced_settings.dart @@ -109,8 +109,7 @@ class AdvancedSettings extends SettingSubpageProvider { ), onTap: () { changedArtworkCacheM = !changedArtworkCacheM; - e.execute(); - settings.save(performanceMode: e); + e.executeAndSave(); NamidaNavigator.inst.popMenu(); }, ), diff --git a/lib/ui/widgets/settings/youtube_settings.dart b/lib/ui/widgets/settings/youtube_settings.dart index ef1e6495..c8b16dc1 100644 --- a/lib/ui/widgets/settings/youtube_settings.dart +++ b/lib/ui/widgets/settings/youtube_settings.dart @@ -12,7 +12,7 @@ import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings_card.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; enum _YoutubeSettingKeys { youtubeStyleMiniplayer, @@ -97,7 +97,7 @@ class YoutubeSettings extends SettingSubpageProvider { value: settings.ytTopComments.valueR, onChanged: (isTrue) { settings.save(ytTopComments: !isTrue); - YoutubeController.inst.resetGlowUnderVideo(); + YoutubeMiniplayerUiController.inst.resetGlowUnderVideo(); // -- pop comments subpage in case was inside. if (settings.ytTopComments.value == false) { diff --git a/lib/ui/widgets/video_widget.dart b/lib/ui/widgets/video_widget.dart index c7372523..2d406a7b 100644 --- a/lib/ui/widgets/video_widget.dart +++ b/lib/ui/widgets/video_widget.dart @@ -4,8 +4,11 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_volume_controller/flutter_volume_controller.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:flutter_volume_controller/flutter_volume_controller.dart' show FlutterVolumeController; +import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; import 'package:namida/class/track.dart'; import 'package:namida/class/video.dart'; @@ -24,7 +27,6 @@ import 'package:namida/packages/three_arched_circle.dart'; import 'package:namida/ui/dialogs/edit_tags_dialog.dart'; import 'package:namida/ui/widgets/artwork.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/seek_ready_widget.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/yt_utils.dart'; @@ -367,7 +369,8 @@ class NamidaVideoControlsState extends State with TickerPro Widget _getQualityChip({ required String title, - String subtitle = '', + String? subtitle, + String? thirdLine, IconData? icon, required void Function(bool isSelected) onPlay, required bool selected, @@ -388,15 +391,30 @@ class NamidaVideoControlsState extends State with TickerPro children: [ Icon(icon ?? (isCached ? Broken.tick_circle : Broken.story), size: 20.0), const SizedBox(width: 4.0), - Text( - title, - style: context.textTheme.displayMedium?.copyWith(fontSize: 13.0), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: context.textTheme.displayMedium?.copyWith(fontSize: 13.0), + ), + if (subtitle != null && subtitle != '') + Text( + subtitle, + style: context.textTheme.displaySmall?.copyWith(fontSize: 12.0), + ), + ], + ), + if (thirdLine != null && thirdLine != '') + Text( + thirdLine, + style: context.textTheme.displaySmall?.copyWith(fontSize: 12.0), + ), + ], ), - if (subtitle != '') - Text( - subtitle, - style: context.textTheme.displaySmall?.copyWith(fontSize: 12.0), - ), ], ), ); @@ -547,41 +565,37 @@ class NamidaVideoControlsState extends State with TickerPro ); } // -- fallback images - if (widget.isLocal) { - return ObxO( - rx: Player.inst.currentItem, - builder: (item) { - final track = item is Selectable ? item.track : null; - return ArtworkWidget( - key: ValueKey(track?.path), - track: track, - path: track?.pathToImage, - thumbnailSize: fallbackWidth, + return ObxO( + rx: Player.inst.currentItem, + builder: (item) { + if (item is YoutubeID) { + final vidId = item.id; + return YoutubeThumbnail( + key: Key(vidId), + isImportantInCache: true, width: fallbackWidth, height: fallbackHeight, borderRadius: 0, blur: 0, + videoId: vidId, + displayFallbackIcon: false, compressed: false, + preferLowerRes: false, ); - }); - } - return Obx( - () { - final vidId = Player.inst.currentVideoR?.id ?? (YoutubeController.inst.currentYoutubeMetadataVideo.valueR ?? Player.inst.currentVideoInfo.valueR)?.id; - return YoutubeThumbnail( - key: Key(vidId ?? ''), - isImportantInCache: true, - width: fallbackWidth, - height: fallbackHeight, - borderRadius: 0, - blur: 0, - videoId: vidId, - displayFallbackIcon: false, - compressed: false, - preferLowerRes: false, - ); - }, - ); + } + final track = item is Selectable ? item.track : null; + return ArtworkWidget( + key: ValueKey(track?.path), + track: track, + path: track?.pathToImage, + thumbnailSize: fallbackWidth, + width: fallbackWidth, + height: fallbackHeight, + borderRadius: 0, + blur: 0, + compressed: false, + ); + }); }); final newDeviceInsets = MediaQuery.paddingOf(context); @@ -733,22 +747,38 @@ class NamidaVideoControlsState extends State with TickerPro ? Material( type: MaterialType.transparency, child: Obx(() { - final videoName = widget.isLocal - ? Player.inst.currentTrackR?.track.title ?? '' - : YoutubeController.inst.currentYoutubeMetadataVideo.valueR?.name ?? Player.inst.currentVideoInfo.valueR?.name ?? ''; - final channelName = widget.isLocal - ? Player.inst.currentTrackR?.track.originalArtist ?? '' - : YoutubeController.inst.currentYoutubeMetadataChannel.valueR?.name ?? Player.inst.currentVideoInfo.valueR?.uploaderName ?? ''; + String? videoName; + String? channelName; + + if (widget.isLocal) { + final track = Player.inst.currentTrackR?.track; + videoName = track?.title; + channelName = track?.originalArtist; + } else { + videoName = YoutubeInfoController.current.currentVideoPage.valueR?.videoInfo?.title ?? + YoutubeInfoController.current.currentYTStreams.valueR?.info?.title; + if (videoName == null) { + final vidId = Player.inst.currentVideoR?.id; + if (vidId != null) videoName = YoutubeInfoController.utils.getVideoName(vidId); + } + + channelName = YoutubeInfoController.current.currentVideoPage.valueR?.channelInfo?.title ?? + YoutubeInfoController.current.currentYTStreams.valueR?.info?.channelName; + if (channelName == null) { + final vidId = Player.inst.currentVideoR?.id; + if (vidId != null) channelName = YoutubeInfoController.utils.getVideoChannelName(vidId); + } + } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (videoName != '') + if (videoName != null && videoName != '') Text( videoName, style: context.textTheme.displayLarge?.copyWith(color: const Color.fromRGBO(255, 255, 255, 0.85)), ), - if (channelName != '') + if (channelName != null && channelName != '') Text( channelName, style: context.textTheme.displaySmall?.copyWith(color: const Color.fromRGBO(255, 255, 255, 0.7)), @@ -884,258 +914,293 @@ class NamidaVideoControlsState extends State with TickerPro ), ), ), - Obx(() { - final audioStreamsAll = List.from(YoutubeController.inst.currentYTAudioStreams.valueR); - final streamsMap = {}; // {language: audiostream} - audioStreamsAll.sortBy((e) => e.displayLanguage ?? ''); - audioStreamsAll.loop((e) { - if (e.language != null && e.formatSuffix != 'webm') { - streamsMap[e.language!] = e; - } - }); - if (streamsMap.keys.length <= 1) return const SizedBox(); - - return NamidaPopupWrapper( - openOnTap: true, - onPop: _startTimer, - onTap: () { - _resetTimer(); - setControlsVisibily(true); - }, - children: () => [ - ...streamsMap.values.map( - (element) => Obx( - () { - final isSelected1 = element.language == Player.inst.currentCachedAudio.valueR?.langaugeCode; - final isSelected2 = element.language == Player.inst.currentAudioStream.valueR?.language; - final isSelected = isSelected1 || isSelected2; - final id = Player.inst.currentVideoR?.id; - return _getQualityChip( - title: '${element.displayLanguage}', - subtitle: " • ${element.language ?? 0}", - onPlay: (isSelected) { - if (!isSelected) { - Player.inst.onItemPlayYoutubeIDSetAudio( - stream: element, - cachedFile: null, - useCache: true, - videoId: Player.inst.currentVideo?.id ?? '', - ); - } - }, - selected: isSelected, - isCached: element.getCachedFile(id) != null, - ); - }, - ), - ), - ], - child: Padding( - padding: const EdgeInsets.all(4.0), - child: BorderRadiusClip( - borderRadius: BorderRadius.circular(6.0.multipliedRadius), - child: NamidaBgBlur( - blur: 3.0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.2), - borderRadius: BorderRadius.circular(6.0.multipliedRadius), - ), - child: Obx( + ObxO( + rx: YoutubeInfoController.current.currentYTStreams, + builder: (streams) { + final currentYTAudioStreams = streams?.audioStreams; + if (currentYTAudioStreams == null || currentYTAudioStreams.isEmpty) return const SizedBox(); + final audioStreamsAll = List.from(currentYTAudioStreams); // check below + final streamsMap = {}; // {language: audiostream} + audioStreamsAll.sortBy((e) => e.audioTrack?.displayName ?? ''); + audioStreamsAll.loop((e) { + if (!e.isWebm) { + final langCode = e.audioTrack?.langCode; + if (langCode != null) streamsMap[langCode] = e; + } + }); + if (streamsMap.keys.length <= 1) return const SizedBox(); + + return NamidaPopupWrapper( + openOnTap: true, + onPop: _startTimer, + onTap: () { + _resetTimer(); + setControlsVisibily(true); + }, + children: () => [ + ...streamsMap.values.map( + (element) => Obx( () { - final currentStream = Player.inst.currentAudioStream.valueR; - final currentCached = Player.inst.currentCachedAudio.valueR; - final qt = currentStream?.displayLanguage ?? currentCached?.langaugeName; - return qt == null - ? const SizedBox() - : Text( - qt, - style: context.textTheme.displaySmall?.copyWith(color: itemsColor), - ); - }, - ), - ), - ), - ), - ), - ); - }), - // ===== Quality Chip ===== - Obx( - () { - final ytQualities = - (widget.isLocal ? VideoController.inst.currentYTQualities : YoutubeController.inst.currentYTQualities).where((s) => s.formatSuffix != 'webm'); - final cachedQualitiesAll = widget.isLocal ? VideoController.inst.currentPossibleVideos : YoutubeController.inst.currentCachedQualities; - final cachedQualities = List.from(cachedQualitiesAll.valueR); - final videoId = Player.inst.currentVideoR?.id; - cachedQualities.removeWhere( - (cq) { - return ytQualities.any((ytq) { - if (widget.isLocal) return ytq.height == cq.height; - final cachePath = videoId == null ? null : ytq.cachePath(videoId); - if (cachePath == cq.path) return true; - if (ytq.sizeInBytes == cq.sizeInBytes) return true; - final sameRes = ytq.resolution == null ? false : cq.resolution.toString().startsWith(ytq.resolution!); // 720p.startsWith(720p60). - final sameFrames = ytq.fps == null ? true : ytq.fps == cq.framerate; - return sameRes && sameFrames; - }); - }, - ); - return NamidaPopupWrapper( - openOnTap: true, - onPop: _startTimer, - onTap: () { - _resetTimer(); - setControlsVisibily(true); - }, - children: () => [ - Obx( - () => _getQualityChip( - title: lang.AUDIO_ONLY, - onPlay: (isSelected) { - Player.inst.setAudioOnlyPlayback(true); - VideoController.inst.currentVideo.value = null; - settings.save(enableVideoPlayback: false); - }, - selected: (widget.isLocal ? VideoController.inst.currentVideo.valueR == null : settings.ytIsAudioOnlyMode.valueR), - isCached: false, - icon: Broken.musicnote, - ), - ), - ...cachedQualities.map( - (element) => Obx( - () => _getQualityChip( - title: '${element.resolution}p${element.framerateText()}', - subtitle: " • ${element.sizeInBytes.fileSizeFormatted}", - onPlay: (isSelected) { - // sometimes video is not initialized so we need the second check - if (!isSelected || Player.inst.videoPlayerInfo.value?.isInitialized != true) { - Player.inst.onItemPlayYoutubeIDSetQuality( - stream: null, - cachedFile: File(element.path), - videoItem: element, - useCache: true, - videoId: Player.inst.currentVideo?.id ?? '', - ); - if (widget.isLocal) { - VideoController.inst.currentVideo.value = element; - settings.save(enableVideoPlayback: true); + bool isSelected = false; + final audioTrack = element.audioTrack; + final langCode = audioTrack?.langCode; + if (langCode != null) { + if (langCode == Player.inst.currentCachedAudio.valueR?.langaugeCode) { + isSelected = true; + } else if (langCode == Player.inst.currentAudioStream.valueR?.audioTrack?.langCode) { + isSelected = true; } } - }, - selected: widget.isLocal - ? VideoController.inst.currentVideo.valueR?.path == element.path - : settings.ytIsAudioOnlyMode.valueR - ? false - : Player.inst.currentCachedVideo.valueR?.path == element.path, - isCached: true, - ), - ), - ), - ...ytQualities.map((element) { - final sizeInBytes = element.sizeInBytes; - return Obx( - () { - if (widget.isLocal) { final id = Player.inst.currentVideoR?.id; - final isSelected = element.height == VideoController.inst.currentVideo.valueR?.height; - return _getQualityChip( - title: element.resolution ?? '', - subtitle: sizeInBytes == null ? '' : " • ${sizeInBytes.fileSizeFormatted}", + title: audioTrack?.displayName ?? '?', + subtitle: " • ${audioTrack?.langCode ?? 0}", onPlay: (isSelected) { - if (!isSelected || Player.inst.videoPlayerInfo.value?.isInitialized != true) { - Player.inst.onItemPlayYoutubeIDSetQuality( + if (!isSelected) { + Player.inst.onItemPlayYoutubeIDSetAudio( stream: element, + mainStreams: streams, cachedFile: null, useCache: true, - videoId: id ?? '', + videoId: Player.inst.currentVideo?.id ?? '', ); } }, selected: isSelected, - isCached: isSelected, + isCached: element.getCachedFile(id) != null, ); - } else { - final id = Player.inst.currentVideoR?.id; - final cachedFile = id == null ? null : element.getCachedFile(id); - final isSelected = settings.ytIsAudioOnlyMode.valueR - ? false - : (element.resolution == Player.inst.currentVideoStream.valueR?.resolution || - (Player.inst.currentCachedVideo.valueR != null && cachedFile?.path == Player.inst.currentCachedVideo.valueR?.path)); - - return _getQualityChip( - title: element.resolution ?? '', - subtitle: sizeInBytes == null ? '' : " • ${sizeInBytes.fileSizeFormatted}", - onPlay: (isSelected) { - if (!isSelected) { - Player.inst.onItemPlayYoutubeIDSetQuality( - stream: element, - cachedFile: cachedFile, - useCache: true, - videoId: id ?? '', - ); - } + }, + ), + ), + ], + child: Padding( + padding: const EdgeInsets.all(4.0), + child: BorderRadiusClip( + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + child: NamidaBgBlur( + blur: 3.0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.2), + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + ), + child: Obx( + () { + final displayName = + Player.inst.currentAudioStream.valueR?.audioTrack?.displayName ?? Player.inst.currentCachedAudio.valueR?.langaugeName; + return displayName == null || displayName == '' + ? const SizedBox() + : Text( + displayName, + style: context.textTheme.displaySmall?.copyWith(color: itemsColor), + ); }, - selected: isSelected, - isCached: cachedFile != null, + ), + ), + ), + ), + ), + ); + }), + // ===== Quality Chip ===== + NamidaPopupWrapper( + openOnTap: true, + onPop: _startTimer, + onTap: () { + _resetTimer(); + setControlsVisibily(true); + }, + children: () { + VideoStreamsResult? streams; + if (widget.isLocal) { + streams = VideoController.inst.currentYTStreams.value; + } else { + streams = YoutubeInfoController.current.currentYTStreams.value; + } + final ytQualities = streams?.videoStreams.where((s) => !s.isWebm); + final cachedQualitiesAll = widget.isLocal ? VideoController.inst.currentPossibleLocalVideos : YoutubeInfoController.current.currentCachedQualities; + final cachedQualities = List.from(cachedQualitiesAll.value); + final videoId = Player.inst.currentVideoR?.id; + if (ytQualities != null && ytQualities.isNotEmpty) { + cachedQualities.removeWhere( + (cq) { + return ytQualities.any((ytq) { + if (widget.isLocal) return ytq.height == cq.height; + final cachePath = videoId == null ? null : ytq.cachePath(videoId); + if (cachePath == cq.path) return true; + if (ytq.sizeInBytes == cq.sizeInBytes) return true; + final sameRes = cq.resolution.toString().startsWith(ytq.qualityLabel); // 720p.startsWith(720p60). + if (!sameRes) return false; + final sameFrames = ytq.fps == cq.framerate; + if (!sameFrames) return false; + return true; // same res && same frames + }); + }, + ); + } + return [ + Obx( + () => _getQualityChip( + title: lang.AUDIO_ONLY, + onPlay: (isSelected) { + Player.inst.setAudioOnlyPlayback(true); + VideoController.inst.currentVideo.value = null; + settings.save(enableVideoPlayback: false); + }, + selected: (widget.isLocal ? VideoController.inst.currentVideo.valueR == null : settings.ytIsAudioOnlyMode.valueR), + isCached: false, + icon: Broken.musicnote, + ), + ), + ...cachedQualities.map( + (element) => Obx( + () => _getQualityChip( + title: '${element.resolution}p${element.framerateText()}', + subtitle: " • ${element.sizeInBytes.fileSizeFormatted}", + onPlay: (isSelected) { + // sometimes video is not initialized so we need the second check + if (!isSelected || Player.inst.videoPlayerInfo.value?.isInitialized != true) { + Player.inst.onItemPlayYoutubeIDSetQuality( + mainStreams: streams, + stream: null, + cachedFile: File(element.path), + videoItem: element, + useCache: true, + videoId: Player.inst.currentVideo?.id ?? '', ); + if (widget.isLocal) { + VideoController.inst.currentVideo.value = element; + settings.save(enableVideoPlayback: true); + } } }, - ); - }), - ], - child: Padding( - padding: const EdgeInsets.all(4.0), - child: BorderRadiusClip( - borderRadius: BorderRadius.circular(6.0.multipliedRadius), - child: NamidaBgBlur( - blur: 3.0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.2), - borderRadius: BorderRadius.circular(6.0.multipliedRadius), - ), - child: Obx( - () { - final isAudio = widget.isLocal ? VideoController.inst.currentVideo.valueR == null : settings.ytIsAudioOnlyMode.valueR; - - String? qt; - if (!isAudio) { - if (widget.isLocal) { - final video = VideoController.inst.currentVideo.valueR; - qt = video == null ? null : '${video.resolution}p${video.framerateText()}'; - } else { - qt = Player.inst.currentVideoStream.valueR?.resolution; - } + selected: widget.isLocal + ? VideoController.inst.currentVideo.valueR?.path == element.path + : settings.ytIsAudioOnlyMode.valueR + ? false + : Player.inst.currentCachedVideo.valueR?.path == element.path, + isCached: true, + ), + ), + ), + ...?ytQualities?.map((element) { + return Obx( + () { + if (widget.isLocal) { + final id = Player.inst.currentVideoR?.id; + final isSelected = element.height == VideoController.inst.currentVideo.valueR?.height; + + return _getQualityChip( + title: element.qualityLabel, + subtitle: " • ${element.sizeInBytes.fileSizeFormatted}", + onPlay: (isSelected) { + if (!isSelected || Player.inst.videoPlayerInfo.value?.isInitialized != true) { + Player.inst.onItemPlayYoutubeIDSetQuality( + mainStreams: streams, + stream: element, + cachedFile: null, + useCache: true, + videoId: id ?? '', + ); + } + }, + selected: isSelected, + isCached: isSelected, + ); + } else { + final id = Player.inst.currentVideoR?.id; + final cachedFile = id == null ? null : element.getCachedFile(id); + bool isSelected = settings.ytIsAudioOnlyMode.valueR + ? false + : (element.itag == Player.inst.currentVideoStream.valueR?.itag || + (Player.inst.currentCachedVideo.valueR != null && cachedFile?.path == Player.inst.currentCachedVideo.valueR?.path)); + if (settings.ytIsAudioOnlyMode.valueR) { + isSelected = false; + } else { + final currentVS = Player.inst.currentVideoStream.valueR; + if (currentVS != null) { + isSelected = element.itag == currentVS.itag; + } else { + final currentCachedV = Player.inst.currentCachedVideo.valueR; + if (currentCachedV != null) { + isSelected = cachedFile?.path == currentCachedV.path; } + } + } - return Row( - children: [ - if (qt != null) ...[ - Text( - qt, - style: context.textTheme.displaySmall?.copyWith(color: itemsColor), - ), - const SizedBox(width: 4.0), - ], - Icon( - isAudio ? Broken.musicnote : Broken.setting, - color: itemsColor, - size: 16.0, - ), - ], - ); + return _getQualityChip( + title: element.qualityLabel, + subtitle: " • ${element.sizeInBytes.fileSizeFormatted}", + thirdLine: element.bitrateText(), + onPlay: (isSelected) { + if (!isSelected) { + Player.inst.onItemPlayYoutubeIDSetQuality( + mainStreams: streams, + stream: element, + cachedFile: cachedFile, + useCache: true, + videoId: id ?? '', + ); + } }, - ), - ), + selected: isSelected, + isCached: cachedFile != null, + ); + } + }, + ); + }), + ]; + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: BorderRadiusClip( + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + child: NamidaBgBlur( + blur: 3.0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.2), + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + ), + child: Obx( + () { + final isAudio = widget.isLocal ? VideoController.inst.currentVideo.valueR == null : settings.ytIsAudioOnlyMode.valueR; + + String? qt; + if (!isAudio) { + if (widget.isLocal) { + final video = VideoController.inst.currentVideo.valueR; + qt = video == null ? null : '${video.resolution}p${video.framerateText()}'; + } else { + qt = Player.inst.currentVideoStream.valueR?.qualityLabel; + } + } + + return Row( + children: [ + if (qt != null) ...[ + Text( + qt, + style: context.textTheme.displaySmall?.copyWith(color: itemsColor), + ), + const SizedBox(width: 4.0), + ], + Icon( + isAudio ? Broken.musicnote : Broken.setting, + color: itemsColor, + size: 16.0, + ), + ], + ); + }, ), ), ), - ); - }, + ), + ), ), ], ), @@ -1317,7 +1382,8 @@ class NamidaVideoControlsState extends State with TickerPro iconColor: itemsColor, onPressed: () { _startTimer(); - YTUtils().copyCurrentVideoUrl(Player.inst.getCurrentVideoId); + final id = Player.inst.currentVideo?.id; + if (id != null) YTUtils().copyCurrentVideoUrl(id); }, ), SizedBox(width: widget.isFullScreen ? 12.0 : 10.0), diff --git a/lib/youtube/class/youtube_id.dart b/lib/youtube/class/youtube_id.dart index c9750a82..2f7002ad 100644 --- a/lib/youtube/class/youtube_id.dart +++ b/lib/youtube/class/youtube_id.dart @@ -4,13 +4,12 @@ import 'dart:io'; import 'package:history_manager/history_manager.dart'; import 'package:playlist_manager/module/playlist_id.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/core/url_utils.dart'; import 'package:namida/class/track.dart'; import 'package:namida/class/video.dart'; import 'package:namida/controller/thumbnail_manager.dart'; import 'package:namida/core/extensions.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; class YoutubeID implements Playable, ItemWithDate { final String id; @@ -62,33 +61,13 @@ class YoutubeID implements Playable, ItemWithDate { } extension YoutubeIDUtils on YoutubeID { - Future toVideoInfo() async { - return await YoutubeController.inst.fetchVideoDetails(id); - } - - Future getThumbnail() async { - return await ThumbnailManager.inst.getYoutubeThumbnailAndCache(id: id); - } - File? getThumbnailSync() { return ThumbnailManager.inst.getYoutubeThumbnailFromCacheSync(id: id); } - - Future getDuration() async { - Duration? dur; - final a = YoutubeController.inst.getVideoInfo(id, checkFromStorage: true); - dur = a?.duration; - - if (dur == null) { - final b = await YoutubeController.inst.fetchVideoDetails(id); - dur = b?.duration; - } - return dur; - } } extension YoutubeIDSUtils on List { Future shareVideos() async { - await Share.share(map((e) => "${YoutubeController.inst.getYoutubeLink(e.id)} - ${e.dateTimeAdded.millisecondsSinceEpoch.dateAndClockFormattedOriginal}\n").join()); + await Share.share(map((e) => "${YTUrlUtils.buildVideoUrl(e.id)} - ${e.dateTimeAdded.millisecondsSinceEpoch.dateAndClockFormattedOriginal}\n").join()); } } diff --git a/lib/youtube/class/youtube_item_download_config.dart b/lib/youtube/class/youtube_item_download_config.dart index f920bdc1..699c85d0 100644 --- a/lib/youtube/class/youtube_item_download_config.dart +++ b/lib/youtube/class/youtube_item_download_config.dart @@ -1,4 +1,5 @@ -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; class YoutubeItemDownloadConfig { final String id; @@ -6,10 +7,9 @@ class YoutubeItemDownloadConfig { final Map ffmpegTags; DateTime? fileDate; VideoStream? videoStream; - AudioOnlyStream? audioStream; + AudioStream? audioStream; final String? prefferedVideoQualityID; final String? prefferedAudioQualityID; - final bool fetchMissingStreams; YoutubeItemDownloadConfig({ required this.id, @@ -20,20 +20,26 @@ class YoutubeItemDownloadConfig { required this.audioStream, required this.prefferedVideoQualityID, required this.prefferedAudioQualityID, - required this.fetchMissingStreams, }); factory YoutubeItemDownloadConfig.fromJson(Map map) { + VideoStream? vids; + AudioStream? auds; + try { + vids = VideoStream.fromMap(map['videoStream']); + } catch (_) {} + try { + auds = AudioStream.fromMap(map['audioStream']); + } catch (_) {} return YoutubeItemDownloadConfig( id: map['id'] ?? 'UNKNOWN_ID', filename: map['filename'] ?? 'UNKNOWN_FILENAME', fileDate: DateTime.fromMillisecondsSinceEpoch(map['fileDate'] ?? 0), ffmpegTags: (map['ffmpegTags'] as Map?)?.cast() ?? {}, - videoStream: map['videoStream'] == null ? null : VideoStream.fromMap(map['videoStream']), - audioStream: map['audioStream'] == null ? null : AudioOnlyStream.fromMap(map['audioStream']), + videoStream: vids, + audioStream: auds, prefferedVideoQualityID: map['prefferedVideoQualityID'], prefferedAudioQualityID: map['prefferedAudioQualityID'], - fetchMissingStreams: map['fetchMissingStreams'] ?? true, ); } @@ -43,11 +49,10 @@ class YoutubeItemDownloadConfig { 'filename': filename, 'ffmpegTags': ffmpegTags, 'fileDate': fileDate?.millisecondsSinceEpoch, - 'videoStream': videoStream?.toMap()?..remove('url'), - 'audioStream': audioStream?.toMap()?..remove('url'), + 'videoStream': videoStream?.toMap(), + 'audioStream': audioStream?.toMap(), 'prefferedVideoQualityID': prefferedVideoQualityID, 'prefferedAudioQualityID': prefferedAudioQualityID, - 'fetchMissingStreams': fetchMissingStreams, }; } diff --git a/lib/youtube/class/yt_thumbnail_wrapper.dart b/lib/youtube/class/yt_thumbnail_wrapper.dart deleted file mode 100644 index 1f838173..00000000 --- a/lib/youtube/class/yt_thumbnail_wrapper.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; - -class YTThumbnail { - final String id; - const YTThumbnail(this.id); - String get maxResUrl => StreamThumbnail(id).maxresdefault; - String get hqdefault => StreamThumbnail(id).hqdefault; - String get mqdefault => StreamThumbnail(id).mqdefault; - String get sddefault => StreamThumbnail(id).sddefault; - String get lowres => StreamThumbnail(id).lowres; - List get allQualitiesByHighest => [maxResUrl, hqdefault, mqdefault, sddefault, lowres]; - List get allQualitiesExceptHighest => [hqdefault, mqdefault, sddefault, lowres]; -} diff --git a/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart b/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart new file mode 100644 index 00000000..e56576a5 --- /dev/null +++ b/lib/youtube/controller/info_controllers/yt_channel_info_controller.dart @@ -0,0 +1,36 @@ +part of namidayoutubeinfo; + +class _ChannelInfoController { + const _ChannelInfoController(); + + Future fetchChannelInfo({required String? channelId, String? handle, ExecuteDetails? details}) async { + final res = await YoutiPie.channel.fetchChannelPage(channelId: channelId, handle: handle, details: details); + return res; + } + + YoutiPieChannelPageResult? fetchChannelInfoSync(String channelId) { + final res = YoutiPie.cacheBuilder.forChannel(channelId: channelId); + return res.read(); + } + + Future fetchChannelAbout({required YoutiPieChannelPageResult channel}) async { + final res = await YoutiPie.channel.fetchChannelAbout(channel: channel); + return res; + } + + ChannelPageAbout? fetchChannelAboutSync(String channelId) { + final res = YoutiPie.cacheBuilder.forChannelAbout(channelId: channelId); + return res.read(); + } + + Future fetchChannelTab({required String channelId, required ChannelTab tab, ExecuteDetails? details}) async { + final res = await YoutiPie.channel.fetchChannelTab(channelId: channelId, tab: tab, details: details); + if (res is YoutiPieChannelTabVideosResult) return res; + return null; + } + + YoutiPieChannelTabVideosResult? fetchChannelTabSync({required String channelId, required ChannelTab tab}) { + final res = YoutiPie.cacheBuilder.forChannelTab(channelId: channelId, tab: tab); + return res.read(); + } +} diff --git a/lib/youtube/controller/info_controllers/yt_search_info_controller.dart b/lib/youtube/controller/info_controllers/yt_search_info_controller.dart new file mode 100644 index 00000000..f4f57424 --- /dev/null +++ b/lib/youtube/controller/info_controllers/yt_search_info_controller.dart @@ -0,0 +1,26 @@ +part of namidayoutubeinfo; + +class _SearchInfoController { + const _SearchInfoController(); + + Future search( + String query, { + ExecuteDetails? details, + bool peopleAlsoWatched = true, + }) async { + return YoutiPie.search.search( + query, + details: details, + peopleAlsoWatched: peopleAlsoWatched, + ); + } + + YoutiPieSearchResult? searchSync( + String query, { + ExecuteDetails? details, + bool peopleAlsoWatched = true, + }) { + final cache = YoutiPie.cacheBuilder.forSearchResults(query: query); + return cache.read(); + } +} diff --git a/lib/youtube/controller/info_controllers/yt_various_utils.dart b/lib/youtube/controller/info_controllers/yt_various_utils.dart new file mode 100644 index 00000000..dbd4f5ba --- /dev/null +++ b/lib/youtube/controller/info_controllers/yt_various_utils.dart @@ -0,0 +1,86 @@ +part of '../youtube_info_controller.dart'; + +class _YoutubeInfoUtils { + _YoutubeInfoUtils._(); + + Map get tempVideoInfosFromStreams => YoutubeInfoController.memoryCache.streamInfoItem.temp; + + /// Used for easily displaying title & channel inside history directly without needing to fetch or rely on cache. + /// This comes mainly after a youtube history import + var tempBackupVideoInfo = {}; // {id: YoutubeVideoHistory()} + + Future fillBackupInfoMap() async { + final map = await _fillBackupInfoMapIsolate.thready(AppDirs.YT_STATS); + tempBackupVideoInfo = map; + tempBackupVideoInfo.remove(''); + } + + static Map _fillBackupInfoMapIsolate(String dirPath) { + final map = {}; + for (final f in Directory(dirPath).listSyncSafe()) { + if (f is File) { + try { + final response = f.readAsJsonSync(); + if (response != null) { + for (final r in response as List) { + final yvh = YoutubeVideoHistory.fromJson(r); + map[yvh.id] = yvh; + } + } + } catch (e) { + continue; + } + } + } + return map; + } + + StreamInfoItem? getStreamInfoSync(String videoId) { + return YoutiPie.cacheBuilder.forStreamInfoItem(videoId: videoId).read(); + } + + VideoStreamsResult? _getVideoStreamResultSync(String videoId) { + return YoutubeInfoController.video.fetchVideoStreamsSync(videoId, bypassJSCheck: true); + } + + YoutiPieVideoPageResult? _getVideoPageResultSync(String videoId) { + return YoutubeInfoController.video.fetchVideoPageSync(videoId); + } + + String? getVideoName(String videoId, {bool checkFromStorage = true /* am sorry every follow me */}) { + String? name = tempVideoInfosFromStreams[videoId]?.title ?? tempBackupVideoInfo[videoId]?.title; + if (name != null || checkFromStorage == false) return name; + return getStreamInfoSync(videoId)?.title ?? + _getVideoStreamResultSync(videoId)?.info?.title ?? // + _getVideoPageResultSync(videoId)?.videoInfo?.title; + } + + String? getVideoChannelName(String videoId, {bool checkFromStorage = true}) { + String? name = tempVideoInfosFromStreams[videoId]?.channelName ?? tempBackupVideoInfo[videoId]?.channel; + if (name != null || checkFromStorage == false) return name; + return getStreamInfoSync(videoId)?.channelName ?? + _getVideoStreamResultSync(videoId)?.info?.channelName ?? // + _getVideoPageResultSync(videoId)?.channelInfo?.title; + } + + String? getVideoChannelID(String videoId) { + return tempVideoInfosFromStreams[videoId]?.channelId ?? + getStreamInfoSync(videoId)?.channelId ?? + _getVideoStreamResultSync(videoId)?.info?.channelId ?? // + _getVideoPageResultSync(videoId)?.channelInfo?.id; + } + + DateTime? getVideoReleaseDate(String videoId) { + // -- we check for streams result first cuz others are approximation. + return _getVideoStreamResultSync(videoId)?.info?.publishedAt.date ?? + tempVideoInfosFromStreams[videoId]?.publishedAt.date ?? + getStreamInfoSync(videoId)?.publishedAt.date ?? // + _getVideoPageResultSync(videoId)?.videoInfo?.publishedAt.date; + } + + int? getVideoDurationSeconds(String videoId) { + return tempVideoInfosFromStreams[videoId]?.durSeconds ?? + getStreamInfoSync(videoId)?.durSeconds ?? // + _getVideoStreamResultSync(videoId)?.info?.durSeconds; + } +} diff --git a/lib/youtube/controller/info_controllers/yt_video_info_controller.dart b/lib/youtube/controller/info_controllers/yt_video_info_controller.dart new file mode 100644 index 00000000..8f754148 --- /dev/null +++ b/lib/youtube/controller/info_controllers/yt_video_info_controller.dart @@ -0,0 +1,48 @@ +part of namidayoutubeinfo; + +class _VideoInfoController { + const _VideoInfoController(); + + static const _usedClient = InnertubeClients.ios; // TODO: tvEmbedded should be used to bypass age restricted and obtain higher quality streams. + static const _requiresJSPlayer = false; + + Future fetchVideoPage(String videoId, {ExecuteDetails? details}) async { + final relatedVideosParams = YoutubeInfoController.current._relatedVideosParams; + final res = await YoutiPie.video.fetchVideoPage(videoId: videoId, relatedVideosParams: relatedVideosParams, details: details); + return res; + } + + /// By default, this will force a network request since most implementations use this as fallback to [fetchVideoPageSync]. + Future fetchVideoStreams(String videoId, {bool forceRequest = true}) async { + final res = await YoutiPie.video.fetchVideoStreams( + id: videoId, + details: forceRequest ? ExecuteDetails.forceRequest() : null, + client: _usedClient, + ); + if (_requiresJSPlayer) { + // -- await preparing before returning result + if (!YoutiPie.cipher.isPrepared) { + await YoutubeInfoController.ensureJSPlayerInitialized(); + } + } + return res; + } + + YoutiPieVideoPageResult? fetchVideoPageSync(String videoId) { + final res = YoutiPie.cacheBuilder.forVideoPage(videoId: videoId); + return res.read(); + } + + VideoStreamsResult? fetchVideoStreamsSync(String videoId, {bool bypassJSCheck = false}) { + final res = YoutiPie.cacheBuilder.forVideoStreams(videoId: videoId); + final cached = res.read(); + if (cached == null || cached.client != _usedClient) return null; + if (_requiresJSPlayer) { + if (!YoutiPie.cipher.isPrepared) { + YoutubeInfoController.ensureJSPlayerInitialized(); + if (bypassJSCheck == false) return null; // the player is not prepared, hence the urls are just useless + } + } + return cached; + } +} diff --git a/lib/youtube/controller/youtube_controller.dart b/lib/youtube/controller/youtube_controller.dart index 1c3a5f79..cb00b9c7 100644 --- a/lib/youtube/controller/youtube_controller.dart +++ b/lib/youtube/controller/youtube_controller.dart @@ -2,17 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:isolate'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart' hide EnumUtils; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:youtipie/class/youtipie_feed/yt_feed_base.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/base/ports_provider.dart'; import 'package:namida/class/http_response_wrapper.dart'; import 'package:namida/class/video.dart'; -import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/ffmpeg_controller.dart'; import 'package:namida/controller/notification_controller.dart'; -import 'package:namida/controller/player_controller.dart'; import 'package:namida/controller/settings_controller.dart'; import 'package:namida/controller/thumbnail_manager.dart'; import 'package:namida/core/constants.dart'; @@ -22,62 +22,18 @@ import 'package:namida/core/utils.dart'; import 'package:namida/youtube/class/download_progress.dart'; import 'package:namida/youtube/class/youtube_item_download_config.dart'; import 'package:namida/youtube/controller/parallel_downloads_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_ongoing_finished_downloads.dart'; import 'package:namida/youtube/yt_utils.dart'; class YoutubeController { static YoutubeController get inst => _instance; static final YoutubeController _instance = YoutubeController._internal(); - - YoutubeController._internal() { - scrollController.addListener(() { - final pixels = scrollController.positions.lastOrNull?.pixels; - final hasScrolledEnough = pixels != null && pixels > 40; - _shouldShowGlowUnderVideo.value = hasScrolledEnough; - }); - } - void resetGlowUnderVideo() => _shouldShowGlowUnderVideo.value = false; - - int get _defaultMiniplayerDimSeconds => settings.ytMiniplayerDimAfterSeconds.value; - double get _defaultMiniplayerOpacity => settings.ytMiniplayerDimOpacity.value; - RxBaseCore get canDimMiniplayer => _canDimMiniplayer; - final _canDimMiniplayer = false.obs; - Timer? _dimTimer; - void cancelDimTimer() { - _dimTimer?.cancel(); - _dimTimer = null; - _canDimMiniplayer.value = false; - } - - void startDimTimer() { - cancelDimTimer(); - if (_defaultMiniplayerDimSeconds > 0 && _defaultMiniplayerOpacity > 0) { - _dimTimer = Timer(Duration(seconds: _defaultMiniplayerDimSeconds), () { - _canDimMiniplayer.value = true; - }); - } - } - - final scrollController = ScrollController(); - - RxBaseCore get shouldShowGlowUnderVideo => _shouldShowGlowUnderVideo; - final _shouldShowGlowUnderVideo = false.obs; + YoutubeController._internal(); final homepageFeed = [].obs; - final currentYoutubeMetadataVideo = Rxn(); - final currentYoutubeMetadataChannel = Rxn(); - final currentRelatedVideos = [].obs; - final currentComments = [].obs; - final commentToParsedHtml = {}; - final currentTotalCommentsCount = Rxn(); - final isLoadingComments = false.obs; - final isTitleExpanded = false.obs; - final currentYTQualities = [].obs; - final currentYTAudioStreams = [].obs; - - /// Used as a backup in case of no connection. - final currentCachedQualities = [].obs; + // final commentToParsedHtml = {}; /// {id: {}} final downloadsVideoProgressMap = >{}.obs; @@ -116,56 +72,6 @@ class YoutubeController { /// {groupName: {filename: File}} final downloadedFilesMap = >{}.obs; - /// Temporarely saves StreamInfoItem info for flawless experience while waiting for real info. - final tempVideoInfosFromStreams = {}; // {id: StreamInfoItem()} - - /// Used for easily displaying title & channel inside history directly without needing to fetch or rely on cache. - /// This comes mainly after a youtube history import - var tempBackupVideoInfo = {}; // {id: YoutubeVideoHistory()} - - late var tempVideoInfo = {}; - late var tempChannelInfo = {}; - - Future loadInfoToMemory() async { - final res = await _loadInfoToMemoryIsolate.thready((AppDirs.YT_METADATA, AppDirs.YT_METADATA_CHANNELS)); - if (res.$1.isNotEmpty) tempVideoInfo = res.$1; - if (res.$2.isNotEmpty) tempChannelInfo = res.$2; - } - - static Future<(Map, Map)> _loadInfoToMemoryIsolate((String pv, String pc) paths) async { - final mv = {}; - final mc = {}; - - final completer1 = Completer(); - final completer2 = Completer(); - - Directory(paths.$1).listAllIsolate().then((value) { - value.loop((e) { - if (e is File) { - try { - final map = e.readAsJsonSync(); - mv[e.path.getFilenameWOExt] = VideoInfo.fromMap(map); - } catch (_) {} - } - }); - completer1.complete(); - }); - Directory(paths.$2).listAllIsolate().then((value) { - value.loop((e) { - if (e is File) { - try { - final map = e.readAsJsonSync(); - mc[e.path.getFilenameWOExt] = YoutubeChannel.fromMap(map); - } catch (_) {} - } - }); - completer2.complete(); - }); - await completer1.future; - await completer2.future; - return (mv, mc); - } - /// [renameCacheFiles] requires you to stop the download first, otherwise it might result in corrupted files. Future renameConfigFilename({ required YoutubeItemDownloadConfig config, @@ -229,287 +135,6 @@ class YoutubeController { await _writeTaskGroupToStorage(groupName: groupName); } - YoutubeVideoHistory? _getBackupVideoInfo(String id) => tempBackupVideoInfo[id]; - - Future fillBackupInfoMap() async { - final map = await _fillBackupInfoMapIsolate.thready(AppDirs.YT_STATS); - tempBackupVideoInfo = map; - tempBackupVideoInfo.remove(''); - } - - static Map _fillBackupInfoMapIsolate(String dirPath) { - final map = {}; - for (final f in Directory(dirPath).listSyncSafe()) { - if (f is File) { - try { - final response = f.readAsJsonSync(); - if (response != null) { - for (final r in response as List) { - final yvh = YoutubeVideoHistory.fromJson(r); - map[yvh.id] = yvh; - } - } - } catch (e) { - continue; - } - } - } - return map; - } - - String getYoutubeLink(String id) => id.toYTUrl(); - - VideoInfo? getVideoInfo(String id, {bool checkFromStorage = false}) { - final info = _getTemporarelyVideoInfo(id, checkFromStorage: checkFromStorage) ?? _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage); - if (info != null) return info; - final bk = _getBackupVideoInfo(id); - if (bk != null) return VideoInfo(id: id, name: bk.title, uploaderName: bk.channel, uploaderUrl: bk.channelUrl); - return null; - } - - String? getVideoName(String id, {bool checkFromStorage = false}) { - return getTemporarelyStreamInfo(id)?.name ?? - _getBackupVideoInfo(id)?.title ?? // - _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.name; - } - - String? getVideoChannelName(String id, {bool checkFromStorage = false}) { - return getTemporarelyStreamInfo(id)?.uploaderName ?? - _getBackupVideoInfo(id)?.channel ?? // - _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.uploaderName; - } - - DateTime? getVideoReleaseDate(String id, {bool checkFromStorage = false}) { - return getTemporarelyStreamInfo(id)?.date ?? // - _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.date; - } - - VideoInfo? _getTemporarelyVideoInfo(String id, {bool checkFromStorage = false}) { - final r = getTemporarelyStreamInfo(id, checkFromStorage: checkFromStorage); - return r == null ? null : VideoInfo.fromStreamInfoItem(r); - } - - StreamInfoItem? getTemporarelyStreamInfo(String id, {bool checkFromStorage = false}) { - final si = tempVideoInfosFromStreams[id]; - if (si != null) return si; - if (checkFromStorage) { - final file = File('${AppDirs.YT_METADATA_TEMP}$id.txt'); - final res = file.readAsJsonSync(); - if (res != null) { - try { - final strInfo = StreamInfoItem.fromMap(res); - return strInfo; - } catch (_) {} - } - } - return null; - } - - /// Keeps the map at max 2000 items. maintained by least recently used. - void _fillTempVideoInfoMap(Iterable? items) async { - if (items != null) { - final map = tempVideoInfosFromStreams; - - final entriesForStorage = >>[]; - for (final e in items) { - final id = e.id; - if (id != null) { - // -- adding to storage (later) - // -- we only add if it didnt exist in map before, to minimize writes - if (map[id] == null) entriesForStorage.add(MapEntry(id, e.toMap())); - - // -- adding to memory (directly) - map.remove(id); - map[id] = e; - } - } - // -- clearing excess from map to keep it at 2000 - final excess = map.length - 2000; - if (excess > 0) { - final excessKeys = map.keys.take(excess).toList(); - excessKeys.loop((k) => map.remove(k)); - } - - await _saveTemporarelyVideoInfoIsolate.thready({ - 'dirPath': AppDirs.YT_METADATA_TEMP, - 'entries': entriesForStorage, - }); - } - } - - static void _saveTemporarelyVideoInfoIsolate(Map p) { - final dirPath = p['dirPath'] as String; - final entries = p['entries'] as List>>; - - entries.loop((e) { - final file = File('$dirPath${e.key}.txt'); - file.writeAsJsonSync(e.value); - }); - } - - /// Checks if the requested id is still playing, since most functions are async and will often - /// take time to fetch from internet, and user may have played other vids, this covers such cases. - bool _canSafelyModifyMetadata(String id) => Player.inst.currentVideo?.id == id; - - Future prepareHomeFeed() async { - homepageFeed.clear(); - final videos = await NewPipeExtractorDart.trending.getTrendingVideos(); - _fillTempVideoInfoMap(videos); - homepageFeed.addAll([ - ...videos, - ]); - } - - Future searchForItems(String text) async { - try { - final videos = await NewPipeExtractorDart.search.searchYoutube(text, []); - _fillTempVideoInfoMap(videos.searchVideos); - return videos.dynamicSearchResultsList; - } catch (_) { - return []; - } - } - - Future searchNextPage() async { - final parsedList = await NewPipeExtractorDart.search.getNextPage(); - final v = YoutubeSearch( - query: '', - searchVideos: parsedList[0], - searchPlaylists: parsedList[1], - searchChannels: parsedList[2], - ); - _fillTempVideoInfoMap(v.searchVideos); - return v.dynamicSearchResultsList; - } - - Future fetchRelatedVideos(String id) async { - currentRelatedVideos.value = List.filled(20, null, growable: true); - final items = await NewPipeExtractorDart.videos.getRelatedStreams(id.toYTUrl()); - _fillTempVideoInfoMap(items.whereType()); - items.loop((p) { - if (p is YoutubePlaylist) { - YoutubeController.inst.getPlaylistStreams(p, forceInitial: true); - } - }); - if (_canSafelyModifyMetadata(id)) { - currentRelatedVideos.value = [ - ...items, - ]; - } - } - - /// For full list of items, use [streams] getter in [playlist]. - Future> getPlaylistStreams(YoutubePlaylist? playlist, {bool forceInitial = false}) async { - if (playlist == null) return []; - final streams = forceInitial ? await playlist.getStreams() : await playlist.getStreamsNextPage(); - _fillTempVideoInfoMap(streams); - return streams; - } - - Future getPlaylistInfo(String playlistUrl, {bool forceInitial = false}) async { - return await NewPipeExtractorDart.playlists.getPlaylistDetails(playlistUrl); - } - - Future> getChannelStreams(String channelID) async { - if (channelID == '') return []; - final url = 'https://www.youtube.com/channel/$channelID'; - var streams = await NewPipeExtractorDart.channels.getChannelUploads(url); - int retries = 3; - while (streams.isEmpty && retries > 0) { - retries--; - streams = await NewPipeExtractorDart.channels.getChannelUploads(url); - } - _fillTempVideoInfoMap(streams); - return streams; - } - - Future> getChannelStreamsNextPage() async { - final streams = await NewPipeExtractorDart.channels.getChannelNextUploads(); - _fillTempVideoInfoMap(streams); - return streams; - } - - Future _fetchComments(String id, {bool forceRequest = false}) async { - currentTotalCommentsCount.value = null; - currentComments.assignAll(List.filled(20, null)); - - // -- Fetching Comments. - final fetchedComments = []; - final cachedFile = File("${AppDirs.YT_METADATA_COMMENTS}$id.txt"); - - // fetching cache - final userForceNewRequest = ConnectivityController.inst.hasConnection && settings.ytPreferNewComments.value; - if (!forceRequest && !userForceNewRequest && await cachedFile.exists()) { - final res = await cachedFile.readAsJson(); - final commList = (res as List?)?.map((e) => YoutubeComment.fromMap(e)); - if (commList != null && commList.isNotEmpty) { - fetchedComments.addAll(commList); - } - _isCurrentCommentsFromCache = true; - } - // fetching from yt, in case no comments were added, i.e: no cache. - if (fetchedComments.isEmpty) { - final comments = await NewPipeExtractorDart.comments.getComments(id.toYTUrl()); - fetchedComments.addAll(comments); - _isCurrentCommentsFromCache = false; - - if (comments.isNotEmpty) _saveCommentsToStorage(cachedFile, comments); - } - // -- Fetching Comments End. - if (_canSafelyModifyMetadata(id)) { - currentComments.clear(); - _fillCommentsLists(fetchedComments); - currentTotalCommentsCount.value = fetchedComments.firstOrNull?.totalCommentsCount; - } - } - - void _fillCommentsLists(List comments) { - for (final c in comments) { - final cid = c?.commentId; - final ctxt = c?.commentText; - if (cid != null && ctxt != null) { - commentToParsedHtml[cid] = removeCommentHTML(ctxt); - } - } - - currentComments.addAll(comments); - } - - String removeCommentHTML(String htmlComment) { - return HtmlParser.parseHTML(htmlComment.replaceAll('
', '\n')).text; - } - - Future _fetchNextComments(String id) async { - if (_isCurrentCommentsFromCache) return; - final comments = await NewPipeExtractorDart.comments.getNextComments(); - if (_canSafelyModifyMetadata(id)) { - _fillCommentsLists(comments); - - // -- saving to cache - final cachedFile = File("${AppDirs.YT_METADATA_COMMENTS}$id.txt"); - _saveCommentsToStorage(cachedFile, currentComments.value); - } - } - - Future _saveCommentsToStorage(File file, Iterable commListy) async { - await file.writeAsJson(commListy.map((e) => e?.toMap()).toList()); - } - - /// Used to keep track of current comments sources, mainly to - /// prevent fetching next comments when cached version is loaded. - bool get isCurrentCommentsFromCache => _isCurrentCommentsFromCache; - bool _isCurrentCommentsFromCache = false; - - Future updateCurrentComments(String id, {bool fetchNextOnly = false, bool forceRequest = false}) async { - isLoadingComments.value = true; - if (currentComments.isNotEmpty && fetchNextOnly && !forceRequest) { - await _fetchNextComments(id); - } else { - await _fetchComments(id, forceRequest: forceRequest); - } - isLoadingComments.value = false; - } - VideoStream getPreferredStreamQuality(List streams, {List qualities = const [], bool preferIncludeWebm = false}) { final preferredQualities = (qualities.isNotEmpty ? qualities : settings.youtubeVideoQualities.value).map((element) => element.settingLabeltoVideoLabel()); VideoStream? plsLoop(bool webm) { @@ -530,173 +155,12 @@ class YoutubeController { } } - Future updateVideoDetails(String id, {bool forceRequest = false}) async { - if (scrollController.hasClients) { - scrollController.jumpTo(0); - resetGlowUnderVideo(); - } - startDimTimer(); - isTitleExpanded.value = false; - - updateCurrentVideoMetadata(id, forceRequest: forceRequest); - if (settings.youtubeStyleMiniplayer.value) { - updateCurrentComments(id); - fetchRelatedVideos(id); - } - } - - Future updateCurrentVideoMetadata(String id, {bool forceRequest = false}) async { - currentYoutubeMetadataVideo.value = null; - currentYoutubeMetadataChannel.value = null; - - void updateForCurrentID(void Function() fn) { - if (_canSafelyModifyMetadata(id)) { - fn(); - } - } - - final channelUrl = tempVideoInfosFromStreams[id]?.uploaderUrl; - - if (channelUrl != null) { - await Future.wait([ - fetchVideoDetails(id).then((info) { - updateForCurrentID(() { - currentYoutubeMetadataVideo.value = info; - }); - }), - fetchChannelDetails(channelUrl).then((channel) { - updateForCurrentID(() { - currentYoutubeMetadataChannel.value = channel; - }); - }), - ]); - } else { - final info = await fetchVideoDetails(id, forceRequest: forceRequest); - final channel = await fetchChannelDetails(info?.uploaderUrl, forceRequest: forceRequest); - updateForCurrentID(() { - currentYoutubeMetadataVideo.value = info; - currentYoutubeMetadataChannel.value = channel; - }); - } - } - - Future fetchVideoDetails(String id, {bool forceRequest = false}) async { - final cachedFile = File("${AppDirs.YT_METADATA}$id.txt"); - VideoInfo? vi; - if (forceRequest == false && await cachedFile.exists()) { - final res = await cachedFile.readAsJson(); - vi = VideoInfo.fromMap(res); - } else { - final info = await NewPipeExtractorDart.videos.getInfo(id.toYTUrl()); - vi = info; - _cacheVideoInfo(id, info); - } - return vi; - } - - Future _cacheVideoInfo(String id, VideoInfo? info) async { - if (info != null) { - tempVideoInfo[id] = info; - await File("${AppDirs.YT_METADATA}$id.txt").writeAsJson(info.toMap()); - } - } - - /// fetches cache version only. - VideoInfo? _fetchVideoDetailsFromCacheSync(String id, {bool checkFromStorage = false}) { - if (tempVideoInfo[id] != null) return tempVideoInfo[id]!; - if (checkFromStorage) { - final cachedFile = File("${AppDirs.YT_METADATA}$id.txt"); - if (cachedFile.existsSync()) { - final res = cachedFile.readAsJsonSync(); - if (res != null) { - try { - return VideoInfo.fromMap(res); - } catch (_) {} - } - } - } - return null; - } - - YoutubeChannel? fetchChannelDetailsFromCacheSync(String? channelUrl, {bool checkFromStorage = false}) { - final channelId = channelUrl?.split('/').last; - if (tempChannelInfo[channelId] != null) return tempChannelInfo[channelId]!; - if (checkFromStorage) { - final cachedFile = File("${AppDirs.YT_METADATA_CHANNELS}$channelId.txt"); - if (cachedFile.existsSync()) { - try { - final res = cachedFile.readAsJsonSync(); - return YoutubeChannel.fromMap(res); - } catch (_) {} - } - } - return null; - } - - Future fetchChannelDetails(String? channelUrl, {bool forceRequest = false}) async { - final channelId = channelUrl?.split('/').last; - if (channelId == null) return null; - final cachedFile = File("${AppDirs.YT_METADATA_CHANNELS}$channelId.txt"); - YoutubeChannel? vi; - if (!forceRequest && await cachedFile.exists()) { - final res = await cachedFile.readAsJson(); - vi = YoutubeChannel.fromMap(res); - } else { - try { - final info = await NewPipeExtractorDart.channels.channelInfo(channelUrl); - vi = info; - tempChannelInfo[channelId] = info; // saves in memory - cachedFile.writeAsJson(info.toMap()); - } catch (e) { - printy(e, isError: true); - } - } - return vi; - } - - Future> getAvailableVideoStreamsOnly(String id) async { - final videos = await NewPipeExtractorDart.videos.getVideoOnlyStreams(id.toYTUrl()); - _sortVideoStreams(videos); - return videos; - } - - Future> getAvailableAudioOnlyStreams(String id) async { - final audios = await NewPipeExtractorDart.videos.getAudioOnlyStreams(id.toYTUrl()); - _sortAudioStreams(audios); - return audios; - } - - Future getAvailableStreams(String id) async { - final url = id.toYTUrl(); - final video = await NewPipeExtractorDart.videos.getStream(url); - _cacheVideoInfo(id, video.videoInfo); - - _sortVideoStreams(video.videoOnlyStreams); - _sortVideoStreams(video.videoStreams); - _sortAudioStreams(video.audioOnlyStreams); - - return video; - } - - void _sortVideoStreams(List? streams) { - streams?.sortByReverseAlt( - (e) => e.width ?? (int.tryParse(e.resolution?.split('p').firstOrNull ?? '') ?? 0), - (e) => e.fps ?? 0, - ); - } - - void _sortAudioStreams(List? streams) { - streams?.sortByReverseAlt( - (e) => e.bitrate ?? 0, - (e) => e.sizeInBytes ?? 0, - ); - } - void _loopMapAndPostNotification({ required Map> bigMap, required int Function(String key, int progress) speedInBytes, required DateTime startTime, required bool isAudio, + required String? Function(String videoId) titleCallback, }) { final downloadingText = isAudio ? "Audio" : "Video"; for (final bigEntry in bigMap.entries.toList()) { @@ -705,7 +169,7 @@ class YoutubeController { for (final entry in map.entries.toList()) { final p = entry.value.progress; final tp = entry.value.totalProgress; - final title = getVideoName(videoId) ?? videoId; + final title = titleCallback(videoId) ?? videoId; final speedB = speedInBytes(videoId, entry.value.progress); currentSpeedsInByte.value[videoId] ??= {}.obs; currentSpeedsInByte.value[videoId]![entry.key] = speedB; @@ -775,6 +239,12 @@ class YoutubeController { void _startNotificationTimer() { if (_downloadNotificationTimer == null) { final startTime = DateTime.now(); + String? title; + String? titleCallback(String videoId) { + title ??= YoutubeInfoController.utils.getVideoName(videoId); + return title; + } + _downloadNotificationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { _loopMapAndPostNotification( startTime: startTime, @@ -786,6 +256,7 @@ class YoutubeController { _speedMapVideo[key] = newProgress; return speed; }, + titleCallback: titleCallback, ); _loopMapAndPostNotification( startTime: startTime, @@ -797,13 +268,12 @@ class YoutubeController { _speedMapAudio[key] = newProgress; return speed; }, + titleCallback: titleCallback, ); }); } } - Future _getContentSize(String url) async => await NewPipeExtractorDart.httpClient.getContentLength(url); - String cleanupFilename(String filename) => filename.replaceAll(RegExp(r'[*#\$|/\\!^:"]', caseSensitive: false), '_'); Future loadDownloadTasksInfoFile() async { @@ -961,9 +431,7 @@ class YoutubeController { void _breakRetrievingInfoRequest(YoutubeItemDownloadConfig c) { final err = Exception('Download was canceled by the user'); - _completersVAI[c]?.$1.completeErrorIfWasnt(err); - _completersVAI[c]?.$2.completeErrorIfWasnt(err); - _completersVAI[c]?.$3.completeErrorIfWasnt(err); + _completersVAI[c]?.completeErrorIfWasnt(err); } Future cancelDownloadTask({ @@ -1037,7 +505,7 @@ class YoutubeController { } } - final _completersVAI = , Completer, Completer)>{}; + final _completersVAI = >{}; Future downloadYoutubeVideos({ required List itemsConfig, @@ -1061,66 +529,49 @@ class YoutubeController { Future downloady(YoutubeItemDownloadConfig config) async { final videoID = config.id; - _completersVAI[config] = (Completer(), Completer(), Completer()); - - final completerV = _completersVAI[config]!.$1; - final completerA = _completersVAI[config]!.$2; - final completerI = _completersVAI[config]!.$3; + final completer = _completersVAI[config] = Completer(); + final streamResultSync = YoutubeInfoController.video.fetchVideoStreamsSync(videoID); + if (streamResultSync != null && streamResultSync.hasExpired() == false) { + completer.completeIfWasnt(streamResultSync); + } else { + YoutubeInfoController.video.fetchVideoStreams(videoID).then((value) => completer.completeIfWasnt(value)); + } isFetchingData.value[videoID] ??= {}.obs; isFetchingData.value[videoID]![config.filename] = true; try { - final dummyVideoUrl = (config.videoStream?.url == null || config.videoStream?.url == ''); - final dummyAudioUrl = (config.audioStream?.url == null || config.audioStream?.url == ''); - final fetchMissing = config.fetchMissingStreams || (dummyVideoUrl && dummyAudioUrl); - // -- we are using url cuz we remove it when reading from json - if (!audioOnly && (fetchMissing || config.prefferedVideoQualityID != null) && dummyVideoUrl) { - getAvailableVideoStreamsOnly(videoID).then((availableVideos) { - _sortVideoStreams(availableVideos); - if (config.prefferedVideoQualityID != null) { - config.videoStream = availableVideos.firstWhereEff((e) => e.id == config.prefferedVideoQualityID); - } - if (config.videoStream == null || config.videoStream?.url == null || config.videoStream?.url == '') { - final webm = config.filename.endsWith('.webm') || config.filename.endsWith('.WEBM'); - config.videoStream = getPreferredStreamQuality(availableVideos, qualities: preferredQualities, preferIncludeWebm: webm); - } - completerV.completeIfWasnt(); - }); - } else { - completerV.completeIfWasnt(); + final streams = await completer.future; + + // -- video + final videos = streams.videoStreams; + if (config.prefferedVideoQualityID != null) { + config.videoStream = videos.firstWhereEff((e) => e.itag.toString() == config.prefferedVideoQualityID); } - if ((fetchMissing || config.prefferedAudioQualityID != null) && dummyAudioUrl) { - getAvailableAudioOnlyStreams(videoID).then((audios) { - _sortAudioStreams(audios); - if (config.prefferedAudioQualityID != null) { - config.audioStream = audios.firstWhereEff((e) => e.id == config.prefferedAudioQualityID); - } - if (config.audioStream == null || config.audioStream?.url == null || config.audioStream?.url == '') { - config.audioStream = audios.firstWhereEff((e) => e.formatSuffix != 'webm') ?? audios.firstOrNull; - } - completerA.completeIfWasnt(); - }); - } else { - completerA.completeIfWasnt(); + if (config.videoStream == null || config.videoStream?.buildUrl()?.isNotEmpty != true) { + final webm = config.filename.endsWith('.webm') || config.filename.endsWith('.WEBM'); + config.videoStream = getPreferredStreamQuality(videos, qualities: preferredQualities, preferIncludeWebm: webm); } + // -- audio + final audios = streams.audioStreams; + if (config.prefferedAudioQualityID != null) { + config.audioStream = audios.firstWhereEff((e) => e.itag.toString() == config.prefferedAudioQualityID); + } + if (config.audioStream == null || config.audioStream?.buildUrl()?.isNotEmpty != true) { + config.audioStream = audios.firstNonWebm() ?? audios.firstOrNull; + } + + // -- meta info if (config.ffmpegTags.isEmpty) { - fetchVideoDetails(videoID).then((info) { - final meta = YTUtils.getMetadataInitialMap(videoID, info, autoExtract: autoExtractTitleAndArtist); + final info = streams.info; + final meta = YTUtils.getMetadataInitialMap(videoID, info, autoExtract: autoExtractTitleAndArtist); - config.ffmpegTags.addAll(meta); - config.fileDate = info?.date; - completerI.completeIfWasnt(); - }); - } else { - completerI.completeIfWasnt(); + config.ffmpegTags.addAll(meta); + config.fileDate = info?.publishDate.date ?? info?.uploadDate.date; } - - await completerV.future; - await completerA.future; - await completerI.future; } catch (e) { + // -- force break isFetchingData[videoID]?[config.filename] = false; return; } @@ -1135,7 +586,7 @@ class YoutubeController { useCachedVersionsIfAvailable: useCachedVersionsIfAvailable, saveDirectory: saveDirectory, filename: config.filename, - fileExtension: config.videoStream?.formatSuffix ?? config.audioStream?.formatSuffix ?? '', + fileExtension: config.videoStream?.codecInfo.container ?? config.audioStream?.codecInfo.container ?? '', videoStream: config.videoStream, audioStream: config.audioStream, merge: true, @@ -1150,7 +601,6 @@ class YoutubeController { onAudioFileReady: (audioFile) async { final thumbnailFile = await ThumbnailManager.inst.getYoutubeThumbnailAndCache( id: videoID, - channelUrlOrID: null, isImportantInCache: true, ); await YTUtils.writeAudioMetadata( @@ -1261,7 +711,7 @@ class YoutubeController { required String filename, required String fileExtension, required VideoStream? videoStream, - required AudioOnlyStream? audioStream, + required AudioStream? audioStream, required Map ffmpegTags, required bool merge, required bool keepCachedVersionsIfDownloaded, @@ -1334,21 +784,17 @@ class YoutubeController { // --------- Downloading Choosen Video. if (videoStream != null) { final filecache = videoStream.getCachedFile(id); - if (useCachedVersionsIfAvailable && filecache != null && await fileSizeQualified(file: filecache, targetSize: videoStream.sizeInBytes ?? 0)) { + if (useCachedVersionsIfAvailable && filecache != null && await fileSizeQualified(file: filecache, targetSize: videoStream.sizeInBytes)) { videoFile = filecache; isVideoFileCached = true; } else { - if (videoStream.sizeInBytes == null || videoStream.sizeInBytes == 0) { - videoStream.sizeInBytes = await _getContentSize(videoStream.url ?? ''); - } - int bytesLength = 0; downloadsVideoProgressMap.value[id] ??= {}.obs; final downloadedFile = await _checkFileAndDownload( groupName: groupName, - url: videoStream.url ?? '', - targetSize: videoStream.sizeInBytes ?? 0, + url: videoStream.buildUrl() ?? '', + targetSize: videoStream.sizeInBytes, filename: filenameClean, destinationFilePath: _getTempVideoPath( groupName: groupName, @@ -1364,14 +810,14 @@ class YoutubeController { bytesLength += downloadedBytesLength; downloadsVideoProgressMap[id]![filename] = DownloadProgress( progress: bytesLength, - totalProgress: videoStream.sizeInBytes ?? 0, + totalProgress: videoStream.sizeInBytes, ); }, ); videoFile = downloadedFile; } - final qualified = await fileSizeQualified(file: videoFile, targetSize: videoStream.sizeInBytes ?? 0); + final qualified = await fileSizeQualified(file: videoFile, targetSize: videoStream.sizeInBytes); if (qualified) { await onVideoFileReady(videoFile); @@ -1390,20 +836,17 @@ class YoutubeController { // --------- Downloading Choosen Audio. if (skipAudio == false && audioStream != null) { final filecache = audioStream.getCachedFile(id); - if (useCachedVersionsIfAvailable && filecache != null && await fileSizeQualified(file: filecache, targetSize: audioStream.sizeInBytes ?? 0)) { + if (useCachedVersionsIfAvailable && filecache != null && await fileSizeQualified(file: filecache, targetSize: audioStream.sizeInBytes)) { audioFile = filecache; isAudioFileCached = true; } else { - if (audioStream.sizeInBytes == null || audioStream.sizeInBytes == 0) { - audioStream.sizeInBytes = await _getContentSize(audioStream.url ?? ''); - } int bytesLength = 0; downloadsAudioProgressMap.value[id] ??= {}.obs; final downloadedFile = await _checkFileAndDownload( groupName: groupName, - url: audioStream.url ?? '', - targetSize: audioStream.sizeInBytes ?? 0, + url: audioStream.buildUrl() ?? '', + targetSize: audioStream.sizeInBytes, filename: filenameClean, destinationFilePath: _getTempAudioPath( groupName: groupName, @@ -1419,13 +862,13 @@ class YoutubeController { bytesLength += downloadedBytesLength; downloadsAudioProgressMap[id]![filename] = DownloadProgress( progress: bytesLength, - totalProgress: audioStream.sizeInBytes ?? 0, + totalProgress: audioStream.sizeInBytes, ); }, ); audioFile = downloadedFile; } - final qualified = await fileSizeQualified(file: audioFile, targetSize: audioStream.sizeInBytes ?? 0); + final qualified = await fileSizeQualified(file: audioFile, targetSize: audioStream.sizeInBytes); if (qualified) { await onAudioFileReady(audioFile); @@ -1549,9 +992,10 @@ class YoutubeController { File? _latestSingleDownloadingFile; Future downloadYoutubeVideo({ required String id, - VideoStream? stream, - required void Function(List availableStreams) onAvailableQualities, - required void Function(VideoOnlyStream choosenStream) onChoosingQuality, + required VideoStream stream, + required DateTime? creationDate, + required void Function(List availableStreams) onAvailableQualities, + required void Function(VideoStream choosenStream) onChoosingQuality, required void Function(int downloadedBytesLength) downloadingStream, required void Function(int initialFileSize) onInitialFileSize, required bool Function() canStartDownloading, @@ -1560,22 +1004,7 @@ class YoutubeController { NamidaVideo? dv; try { // --------- Getting Video to Download. - late VideoOnlyStream erabaretaStream; - if (stream != null) { - erabaretaStream = stream; - } else { - final availableVideos = await getAvailableVideoStreamsOnly(id); - - _sortVideoStreams(availableVideos); - - onAvailableQualities(availableVideos); - - erabaretaStream = availableVideos.last; // worst quality - - if (stream == null) { - erabaretaStream = getPreferredStreamQuality(availableVideos); - } - } + VideoStream erabaretaStream = stream; onChoosingQuality(erabaretaStream); // ------------------------------------ @@ -1586,7 +1015,7 @@ class YoutubeController { return erabaretaStream.cachePath(id, directory: dir); } - final erabaretaStreamSizeInBytes = erabaretaStream.sizeInBytes ?? 0; + final erabaretaStreamSizeInBytes = erabaretaStream.sizeInBytes; final file = await File(getVPath(true)).create(); // retrieving the temp file (or creating a new one). int initialFileSizeOnDisk = 0; @@ -1613,7 +1042,7 @@ class YoutubeController { _downloadManager.stopDownload(file: _latestSingleDownloadingFile); // disposing old download process _latestSingleDownloadingFile = file; downloaded = await _downloadManager.download( - url: erabaretaStream.url ?? '', + url: erabaretaStream.buildUrl(), file: file, downloadStartRange: downloadStartRange, downloadingStream: downloadingStream, @@ -1627,13 +1056,13 @@ class YoutubeController { path: newFilePath, ytID: id, nameInCache: newFilePath.getFilenameWOExt, - height: erabaretaStream.height ?? 0, - width: erabaretaStream.width ?? 0, + height: erabaretaStream.height, + width: erabaretaStream.width, sizeInBytes: erabaretaStreamSizeInBytes, - frameratePrecise: erabaretaStream.fps?.toDouble() ?? 0.0, - creationTimeMS: 0, // TODO: get using metadata - durationMS: erabaretaStream.durationMS ?? 0, - bitrate: erabaretaStream.bitrate ?? 0, + frameratePrecise: erabaretaStream.fps.toDouble(), + creationTimeMS: creationDate?.millisecondsSinceEpoch ?? 0, + durationMS: erabaretaStream.duration.inMilliseconds, + bitrate: erabaretaStream.bitrate, ); } } catch (e) { @@ -1658,23 +1087,21 @@ class YoutubeController { } } -extension _IDToUrlConvert on String { - String toYTUrl() => 'https://www.youtube.com/watch?v=$this'; -} - class _YTDownloadManager with PortsProvider { final _downloadCompleters = ?>{}; // file path final _progressPorts = {}; // file path /// if [file] is temp, u can provide [moveTo] to move/rename the temp file to it. Future download({ - required String url, + required String? url, required File file, String? moveTo, int? moveToRequiredBytes, required int downloadStartRange, required void Function(int downloadedBytesLength) downloadingStream, }) async { + if (url == null || url.isEmpty) return false; + final filePath = file.path; _downloadCompleters[filePath]?.completeIfWasnt(false); _downloadCompleters[filePath] = Completer(); diff --git a/lib/youtube/controller/youtube_current_info.dart b/lib/youtube/controller/youtube_current_info.dart new file mode 100644 index 00000000..64cacc36 --- /dev/null +++ b/lib/youtube/controller/youtube_current_info.dart @@ -0,0 +1,148 @@ +part of 'youtube_info_controller.dart'; + +class _YoutubeCurrentInfoController { + _YoutubeCurrentInfoController._(); + + RelatedVideosRequestParams get _relatedVideosParams => const RelatedVideosRequestParams.allowAll(); // -- from settings + bool get _canShowComments => settings.youtubeStyleMiniplayer.value; + + RxBaseCore get currentVideoPage => _currentVideoPage; + RxBaseCore get currentComments => _currentComments; + RxBaseCore get isLoadingInitialComments => _isLoadingInitialComments; + RxBaseCore get isLoadingMoreComments => _isLoadingMoreComments; + RxBaseCore get currentFeed => _currentFeed; + + /// Used to keep track of current comments sources, mainly to + /// prevent fetching next comments when cached version is loaded. + RxBaseCore get isCurrentCommentsFromCache => _isCurrentCommentsFromCache; + + /// Used as a backup in case of no connection. + final currentCachedQualities = [].obs; + + final _currentVideoPage = Rxn(); + final _currentRelatedVideos = Rxn(); + final _currentComments = Rxn(); + final currentYTStreams = Rxn(); + final _isLoadingInitialComments = false.obs; + final _isLoadingMoreComments = false.obs; + final _isCurrentCommentsFromCache = Rxn(); + + final _currentFeed = Rxn(); + + String? _initialCommentsContinuation; + + /// Checks if the requested id is still playing, since most functions are async and will often + /// take time to fetch from internet, and user may have played other vids, this covers such cases. + bool _canSafelyModifyMetadata(String id) => Player.inst.currentVideo?.id == id; + + void Function()? onVideoPageReset; + + void resetAll() { + currentCachedQualities.clear(); + _currentVideoPage.value = null; + _currentRelatedVideos.value = null; + _currentComments.value = null; + currentYTStreams.value = null; + _isLoadingInitialComments.value = false; + _isLoadingMoreComments.value = false; + _isCurrentCommentsFromCache.value = null; + } + + Future prepareFeed() async { + final val = await YoutiPie.feed.fetchFeed(); + if (val != null) _currentFeed.value = val; + } + + bool updateVideoPageSync(String videoId) { + final vidcache = YoutiPie.cacheBuilder.forVideoPage(videoId: videoId); + final vidPageCached = vidcache.read(); + _currentVideoPage.value = vidPageCached; + final relatedcache = YoutiPie.cacheBuilder.forRelatedVideos(videoId: videoId); + _currentRelatedVideos.value = relatedcache.read() ?? vidPageCached?.relatedVideosResult; + return vidPageCached != null; + } + + bool updateCurrentCommentsSync(String videoId) { + final commcache = YoutiPie.cacheBuilder.forComments(videoId: videoId); + final comms = commcache.read(); + _currentComments.value = comms; + if (_currentComments.value != null) _isCurrentCommentsFromCache.value = true; + return comms != null; + } + + Future updateVideoPage(String videoId, {required bool forceRequestPage, required bool forceRequestComments, CommentsSortType? commentsSort}) async { + if (!ConnectivityController.inst.hasConnection) { + snackyy( + title: lang.ERROR, + message: lang.NO_NETWORK_AVAILABLE_TO_FETCH_VIDEO_PAGE, + isError: true, + top: false, + ); + return; + } + + if (forceRequestPage) { + if (onVideoPageReset != null) onVideoPageReset!(); // jumps miniplayer to top + _currentVideoPage.value = null; + } + if (forceRequestComments) { + _currentComments.value = null; + _initialCommentsContinuation = null; + } + + commentsSort ??= YoutubeMiniplayerUiController.inst.currentCommentSort.value; + + final page = await YoutubeInfoController.video.fetchVideoPage(videoId, details: forceRequestPage ? ExecuteDetails.forceRequest() : null); + + if (_canSafelyModifyMetadata(videoId)) { + _currentVideoPage.value = page; + if (forceRequestComments) { + final commentsContinuation = page?.commentResult.continuation; + if (commentsContinuation != null && _canShowComments) { + _isLoadingInitialComments.value = true; + final comm = await YoutubeInfoController.comment.fetchComments( + videoId: videoId, + continuationToken: commentsContinuation, + details: ExecuteDetails.forceRequest(), + ); + if (identical(page, _currentVideoPage.value)) { + _isLoadingInitialComments.value = false; + _currentVideoPage.refresh(); + _currentComments.value = comm; + _isCurrentCommentsFromCache.value = false; + _initialCommentsContinuation = comm?.continuation; + } + } + } + } + } + + /// specify [sortType] to force refresh. otherwise fetches next + Future updateCurrentComments(String videoId, {CommentsSortType? sortType, bool initial = false}) async { + final commentRes = _currentComments.value; + if (commentRes == null) return; + + if (initial == false && commentRes.canFetchNext && commentRes.isNotEmpty && sortType == null) { + _isLoadingMoreComments.value = true; + final didFetch = await commentRes.fetchNext(); + if (didFetch) _currentComments.refresh(); + _isLoadingMoreComments.value = false; + } else { + // -- fetch initial. + _isLoadingInitialComments.value = true; + final initialContinuation = sortType == null ? _initialCommentsContinuation : commentRes.sorters[sortType] ?? _initialCommentsContinuation; + if (initialContinuation != null) { + final newRes = await YoutubeInfoController.comment.fetchComments( + videoId: videoId, + continuationToken: initialContinuation, + details: ExecuteDetails.forceRequest(), + ); + if (newRes != null && _canSafelyModifyMetadata(videoId)) { + _currentComments.value = newRes; + _isCurrentCommentsFromCache.value = false; + } + } + _isLoadingInitialComments.value = false; + } + } +} diff --git a/lib/youtube/controller/youtube_import_controller.dart b/lib/youtube/controller/youtube_import_controller.dart index 74312c29..49872321 100644 --- a/lib/youtube/controller/youtube_import_controller.dart +++ b/lib/youtube/controller/youtube_import_controller.dart @@ -79,7 +79,7 @@ class YoutubeImportController { isImportingSubscriptions.value = true; final res = await _parseSubscriptions.thready(subscriptionsFilePath); res.loop((e) { - final valInMap = YoutubeSubscriptionsController.inst.getChannel(e.id); + final valInMap = YoutubeSubscriptionsController.inst.availableChannels.value[e.id]; YoutubeSubscriptionsController.inst.setChannel( e.id, YoutubeSubscription( diff --git a/lib/youtube/controller/youtube_info_controller.dart b/lib/youtube/controller/youtube_info_controller.dart new file mode 100644 index 00000000..54fa7528 --- /dev/null +++ b/lib/youtube/controller/youtube_info_controller.dart @@ -0,0 +1,68 @@ +library namidayoutubeinfo; + +import 'dart:io'; + +import 'package:namida/class/video.dart'; +import 'package:namida/controller/connectivity.dart'; +import 'package:namida/controller/logs_controller.dart' as namidalogs; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/player_controller.dart'; +import 'package:namida/controller/settings_controller.dart'; +import 'package:namida/core/constants.dart'; +import 'package:namida/core/extensions.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; + +import 'package:youtipie/class/channels/channel_page_about.dart'; +import 'package:youtipie/class/channels/channel_page_result.dart'; +import 'package:youtipie/class/channels/channel_tab.dart'; +import 'package:youtipie/class/channels/tabs/channel_tab_videos_result.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/related_videos_request_params.dart'; +import 'package:youtipie/class/result_wrapper/comment_result.dart'; +import 'package:youtipie/class/result_wrapper/feed_result.dart'; +import 'package:youtipie/class/result_wrapper/related_videos_result.dart'; +import 'package:youtipie/class/result_wrapper/search_result.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:youtipie/class/videos/video_result.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/core/http.dart'; +import 'package:youtipie/youtipie.dart' hide logger; + +part 'info_controllers/yt_channel_info_controller.dart'; +part 'info_controllers/yt_search_info_controller.dart'; +part 'info_controllers/yt_various_utils.dart'; +part 'info_controllers/yt_video_info_controller.dart'; +part 'youtube_current_info.dart'; + +class YoutubeInfoController { + YoutubeInfoController._(); + + static const video = _VideoInfoController(); + static const playlist = YoutiPie.playlist; + static const comment = YoutiPie.comment; + static const search = _SearchInfoController(); + static const feed = YoutiPie.feed; + static const channel = _ChannelInfoController(); + + static final memoryCache = YoutiPie.memoryCache; + + static void initialize() { + YoutiPie.initialize( + dataDirectory: AppDirs.YOUTIPIE_CACHE, + sensitiveDataDirectory: AppDirs.YOUTIPIE_DATA, + checkJSPlayer: true, // wont await.. are we cooked? properly + ); + YoutiPie.setLogs(namidalogs.logger.logger); + } + + static Future ensureJSPlayerInitialized() async { + if (YoutiPie.cipher.isPrepared) return true; + return YoutiPie.cipher.prepareJSPlayer(cacheDirectoryPath: AppDirs.YOUTIPIE_CACHE); + } + + static final current = _YoutubeCurrentInfoController._(); + static final utils = _YoutubeInfoUtils._(); +} diff --git a/lib/youtube/controller/youtube_local_search_controller.dart b/lib/youtube/controller/youtube_local_search_controller.dart index 37980b31..63659ae9 100644 --- a/lib/youtube/controller/youtube_local_search_controller.dart +++ b/lib/youtube/controller/youtube_local_search_controller.dart @@ -1,18 +1,23 @@ import 'dart:async'; -import 'dart:io'; import 'dart:isolate'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/cache_details.dart'; +import 'package:youtipie/class/publish_time.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/streams/video_stream_info.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/base/ports_provider.dart'; import 'package:namida/class/video.dart'; import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/utils.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; enum YTLocalSearchSortType { mostPlayed, @@ -97,10 +102,9 @@ class YTLocalSearchController with PortsProvider { @override IsolateFunctionReturnBuild isolateFunction(SendPort port) { final params = { - 'tempStreamInfo': YoutubeController.inst.tempVideoInfosFromStreams, - 'dirStreamInfo': AppDirs.YT_METADATA_TEMP, - 'dirVideoInfo': AppDirs.YT_METADATA, - 'tempBackupYTVH': YoutubeController.inst.tempBackupVideoInfo, + 'databasesDir': AppDirs.YOUTIPIE_CACHE, + 'sensitiveDataDir': AppDirs.YOUTIPIE_DATA, + 'tempBackupYTVH': YoutubeInfoController.utils.tempBackupVideoInfo, 'enableFuzzySearch': enableFuzzySearch, 'sendPort': port, }; @@ -119,32 +123,34 @@ class YTLocalSearchController with PortsProvider { } static void _prepareResourcesAndSearch(Map params) async { - final tempStreamInfo = params['tempStreamInfo'] as Map; - final dirStreamInfo = params['dirStreamInfo'] as String; - final dirVideoInfo = params['dirVideoInfo'] as String; + final databasesDir = params['databasesDir'] as String; + final sensitiveDataDir = params['sensitiveDataDir'] as String; final tempBackupYTVH = params['tempBackupYTVH'] as Map; final enableFuzzySearch = params['enableFuzzySearch'] as bool; final sendPort = params['sendPort'] as SendPort; - final recievePort = ReceivePort(); + final recievePort = ReceivePort(); sendPort.send(recievePort.sendPort); final lookupItemAvailable = {}; - final lookupListStreamInfo = []; - final lookupListStreamInfoMap = []; - final lookupListVideoInfoMap = >[]; + final lookupListStreamInfoMap = >[]; // StreamInfoItem + final lookupListVideoStreamsMap = >[]; // VideoStreamInfo final lookupListYTVH = []; + var lookupListStreamInfoMapCacheDetails = []; + var lookupListVideoStreamsMapCacheDetails = []; + // -- start listening StreamSubscription? streamSub; streamSub = recievePort.listen((p) { if (PortsProvider.isDisposeMessage(p)) { recievePort.close(); - lookupListStreamInfo.clear(); + lookupListStreamInfoMapCacheDetails.loop((item) => item.close()); + lookupListVideoStreamsMapCacheDetails.loop((item) => item.close()); lookupListYTVH.clear(); lookupListStreamInfoMap.clear(); - lookupListVideoInfoMap.clear(); + lookupListVideoStreamsMap.clear(); lookupItemAvailable.clear(); streamSub?.cancel(); return; @@ -161,17 +167,13 @@ class YTLocalSearchController with PortsProvider { final res = lookupItemAvailable[possibleID]; if (res != null) { switch (res.list) { - case 1: - final vid = lookupListStreamInfo[res.index]; - searchResults.add(vid); - break; case 2: final info = lookupListStreamInfoMap[res.index]; searchResults.add(StreamInfoItem.fromMap(info)); break; case 3: - final info = lookupListVideoInfoMap[res.index]; - searchResults.add(VideoInfo.fromMap(info).toStreamInfo()); + final info = lookupListVideoStreamsMap[res.index]; + searchResults.add(VideoStreamInfo.fromMap(info).toStreamInfo()); break; case 4: final info = lookupListYTVH[res.index]; @@ -197,25 +199,12 @@ class YTLocalSearchController with PortsProvider { // ----------------------------------- - if (!shouldBreak()) { - final list1 = lookupListStreamInfo; - final l1 = list1.length; - for (int i = 0; i < l1; i++) { - final info = list1[i]; - if (isMatch(info.name, info.uploaderName)) { - searchResults.add(info); - if (shouldBreak()) break; - } - } - } - // ----------------------------------- - if (!shouldBreak()) { final list2 = lookupListStreamInfoMap; final l2 = list2.length; for (int i = 0; i < l2; i++) { final info = list2[i]; - if (isMatch(info['name'], info['uploaderName'])) { + if (isMatch(info['title'], info['channel']?['title'] as String?)) { searchResults.add(StreamInfoItem.fromMap(info)); if (shouldBreak()) break; } @@ -224,12 +213,12 @@ class YTLocalSearchController with PortsProvider { // ----------------------------------- if (!shouldBreak()) { - final list3 = lookupListVideoInfoMap; + final list3 = lookupListVideoStreamsMap; final l3 = list3.length; for (int i = 0; i < l3; i++) { final info = list3[i]; - if (isMatch(info['name'], info['uploaderName'])) { - searchResults.add(VideoInfo.fromMap(info).toStreamInfo()); + if (isMatch(info['title'], info['channelName'])) { + searchResults.add(VideoStreamInfo.fromMap(info).toStreamInfo()); if (shouldBreak()) break; } } @@ -254,47 +243,42 @@ class YTLocalSearchController with PortsProvider { // -- start filling info final start = DateTime.now(); - for (final id in tempStreamInfo.keys) { - final val = tempStreamInfo[id]!; - lookupListStreamInfo.add(val); - lookupItemAvailable[id] = (list: 1, index: lookupListStreamInfo.length - 1); - } - - final completer1 = Completer(); - final completer2 = Completer(); + YoutiPie.cacheManager.init(databasesDir); + final activeChannel = YoutiPie.getActiveAccountChannelIsolate(sensitiveDataDir); + final activeChannelId = activeChannel?.id; - Directory(dirStreamInfo).listAllIsolate().then((value) { - value.loop((file) { - try { - final res = (file as File).readAsJsonSync(); - if (res != null) { - final id = res['id']; - if (id != null && lookupItemAvailable[id] == null) { - lookupListStreamInfoMap.add(res); - lookupItemAvailable[id] = (list: 2, index: lookupListStreamInfoMap.length - 1); - } + if (activeChannelId != null && activeChannelId.isNotEmpty) { + lookupListStreamInfoMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.streamInfoItem, null, () => activeChannelId)); + lookupListVideoStreamsMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.videoStreams, null, () => activeChannelId)); + } + // -- damn the annonymous acc videos look saxy + lookupListStreamInfoMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.streamInfoItem, null, () => null)); + lookupListVideoStreamsMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.videoStreams, null, () => null)); + + lookupListStreamInfoMapCacheDetails.loop( + (db) { + db.loadEverything((map) { + final id = map['id']; + if (id != null && lookupItemAvailable[id] == null) { + lookupListStreamInfoMap.add(map); + lookupItemAvailable[id] = (list: 2, index: lookupListStreamInfoMap.length - 1); } - } catch (_) {} - }); - completer1.complete(); - }); - Directory(dirVideoInfo).listAllIsolate().then((value) { - value.loop((file) { - try { - final res = (file as File).readAsJsonSync(); - if (res != null) { - final id = res['id']; - if (id != null && lookupItemAvailable[id] == null) { - lookupListVideoInfoMap.add(res.cast()); - lookupItemAvailable[id] = (list: 3, index: lookupListVideoInfoMap.length - 1); - } + }); + }, + ); + lookupListVideoStreamsMapCacheDetails.loop( + (db) { + db.loadEverything((map) { + final info = map['info'] as Map; + final id = info['id']; + if (id != null && lookupItemAvailable[id] == null) { + lookupListVideoStreamsMap.add(info.cast()); + lookupItemAvailable[id] = (list: 3, index: lookupListVideoStreamsMap.length - 1); } - } catch (_) {} - }); - completer2.complete(); - }); - await completer1.future; - await completer2.future; + }); + }, + ); + for (final id in tempBackupYTVH.keys) { if (lookupItemAvailable[id] == null) { final val = tempBackupYTVH[id]!; @@ -305,9 +289,9 @@ class YTLocalSearchController with PortsProvider { sendPort.send(null); // finished filling final durationTaken = start.difference(DateTime.now()); - printo('Initialized 4 Lists in $durationTaken'); - printo('''Initialized _lookupListStreamInfo: ${lookupListStreamInfo.length} | _lookupListStreamInfoMap: ${lookupListStreamInfoMap.length} | - _lookupListVideoInfoMap: ${lookupListVideoInfoMap.length} | _lookupListYTVH: ${lookupListYTVH.length}'''); + printo('Initialized 3 Lists in $durationTaken'); + printo('''Initialized lookupListStreamInfoMap: ${lookupListStreamInfoMap.length} | + lookupListVideoStreamsMap: ${lookupListVideoStreamsMap.length} | lookupListYTVH: ${lookupListYTVH.length}'''); // -- end filling info } @@ -358,46 +342,61 @@ class YTLocalSearchController with PortsProvider { } } -extension _VideoInfoUtils on VideoInfo { +extension _VideoInfoUtils on VideoStreamInfo { StreamInfoItem toStreamInfo() { + final vid = this; + final date = vid.publishedAt.date; return StreamInfoItem( - url: url, - id: id, - name: name, - uploaderName: uploaderName, - uploaderUrl: uploaderUrl, - uploaderAvatarUrl: uploaderAvatarUrl, - thumbnailUrl: thumbnailUrl, - date: date, - textualUploadDate: date == null ? textualUploadDate : Jiffy.parseFromDateTime(date!).fromNow(), - isDateApproximation: isDateApproximation, - duration: duration, - viewCount: viewCount, - isUploaderVerified: isUploaderVerified, - isShortFormContent: isShortFormContent, - shortDescription: description, + id: vid.id, + title: vid.title, + shortDescription: vid.availableDescription, + channel: ChannelInfoItem( + id: vid.channelId ?? '', + handler: '', + title: vid.channelName ?? '', + thumbnails: [], + ), + thumbnailGifUrl: null, + publishedFromText: date == null ? '' : Jiffy.parseFromDateTime(date).fromNow(), + publishedAt: vid.publishedAt, + indexInPlaylist: null, + durSeconds: null, + durText: null, + viewsText: vid.viewsCount.toString(), + viewsCount: vid.viewsCount, + percentageWatched: null, + liveThumbs: vid.thumbnails, + isUploaderVerified: null, + badges: null, ); } } extension _YTVHToVideoInfo on YoutubeVideoHistory { StreamInfoItem toStreamInfo() { + final chId = channelUrl.splitLast('/'); return StreamInfoItem( - url: null, id: id, - name: title, - uploaderName: channel, - uploaderUrl: channelUrl, - uploaderAvatarUrl: null, - thumbnailUrl: null, - date: null, - textualUploadDate: null, - isDateApproximation: null, - duration: null, - viewCount: null, - isUploaderVerified: null, - isShortFormContent: null, + title: title, + channel: ChannelInfoItem( + id: chId, + handler: '', + title: channel, + thumbnails: [], + ), shortDescription: null, + thumbnailGifUrl: null, + publishedFromText: '', + publishedAt: const PublishTime.unknown(), + indexInPlaylist: null, + durSeconds: null, + durText: null, + viewsText: null, + viewsCount: null, + percentageWatched: null, + liveThumbs: [], + isUploaderVerified: null, + badges: [], ); } } diff --git a/lib/youtube/controller/youtube_playlist_controller.dart b/lib/youtube/controller/youtube_playlist_controller.dart index 7dfa4b31..9185562d 100644 --- a/lib/youtube/controller/youtube_playlist_controller.dart +++ b/lib/youtube/controller/youtube_playlist_controller.dart @@ -27,7 +27,7 @@ class YoutubePlaylistController extends PlaylistManager { void addNewPlaylist( String name, { - Iterable videoIds = const [], + Iterable? videoIds, int? creationDate, String comment = '', List moods = const [], @@ -37,7 +37,7 @@ class YoutubePlaylistController extends PlaylistManager { name, tracks: (playlistID) { final newTracks = videoIds - .map( + ?.map( (id) => YoutubeID( id: id, watchNull: YTWatch(dateNull: DateTime.now(), isYTMusic: false), @@ -81,8 +81,8 @@ class YoutubePlaylistController extends PlaylistManager { return newtracks; } - Future favouriteButtonOnPressed(String id) async { - return await super.toggleTrackFavourite( + bool favouriteButtonOnPressed(String id) { + return super.toggleTrackFavourite( newTrack: YoutubeID( id: id, watchNull: YTWatch(dateNull: DateTime.now(), isYTMusic: false), diff --git a/lib/youtube/controller/youtube_subscriptions_controller.dart b/lib/youtube/controller/youtube_subscriptions_controller.dart index 40d1e859..0649d5f4 100644 --- a/lib/youtube/controller/youtube_subscriptions_controller.dart +++ b/lib/youtube/controller/youtube_subscriptions_controller.dart @@ -10,24 +10,29 @@ class YoutubeSubscriptionsController { YoutubeSubscriptionsController._internal(); Iterable get subscribedChannels => _availableChannels.keys.where((key) => _availableChannels[key]?.subscribed == true); + + RxBaseCore> get availableChannels => _availableChannels; final _availableChannels = {}.obs; - YoutubeSubscription? getChannel(String channelId) => _availableChannels[channelId]; void setChannel(String channelId, YoutubeSubscription channel) => _availableChannels[channelId] = channel; String? idOrUrlToChannelID(String? idOrURL) => idOrURL?.splitLast('/'); - /// Updates a channel subscription status, use null to toggle. - Future changeChannelStatus(String channelIDOrURL, {bool? subscribe}) async { + Future toggleChannelSubscription(String channelIDOrURL) async { final channelID = channelIDOrURL.splitLast('/'); - final valInMap = _availableChannels[channelID]; - final newSubscribed = subscribe ?? !(valInMap?.subscribed ?? false); - _availableChannels[channelID] = YoutubeSubscription( + final valInMap = _availableChannels.value[channelID]; + final wasSubscribed = valInMap?.subscribed == true; + final newSubscribed = !wasSubscribed; + + _availableChannels.value[channelID] = YoutubeSubscription( title: valInMap?.title, channelID: channelID, subscribed: newSubscribed, lastFetched: valInMap?.lastFetched, ); + _availableChannels.refresh(); + await saveFile(); + return newSubscribed; } Future sortByLastFetched() async { diff --git a/lib/youtube/controller/yt_generators_controller.dart b/lib/youtube/controller/yt_generators_controller.dart index 9f934763..dbfe3ef0 100644 --- a/lib/youtube/controller/yt_generators_controller.dart +++ b/lib/youtube/controller/yt_generators_controller.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'dart:io'; import 'dart:isolate'; import 'package:flutter/services.dart'; import 'package:history_manager/history_manager.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; -import 'package:newpipeextractor_dart/utils/stringChecker.dart'; +import 'package:youtipie/class/cache_details.dart'; +import 'package:youtipie/class/publish_time.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/base/generator_base.dart'; import 'package:namida/base/ports_provider.dart'; @@ -15,8 +16,8 @@ import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; class NamidaYTGenerator extends NamidaGeneratorBase with PortsProvider { @@ -42,10 +43,8 @@ class NamidaYTGenerator extends NamidaGeneratorBase with Port return ids.map((e) => YoutubeID(id: e, playlistID: null)); } - Future> generateVideoFromSameEra(String videoId, {int daysRange = 30, String? videoToRemove}) async { + Future> generateVideoFromSameEra(String videoId, DateTime date, {int daysRange = 30, String? videoToRemove}) async { const type = _GenerateOperation.sameReleaseDate; - final date = YoutubeController.inst.getVideoReleaseDate(videoId, checkFromStorage: true) ?? - await NewPipeExtractorDart.videos.getInfo('https://www.youtube.com/watch?v=$videoId').then((value) => value?.date); final p = {'type': type, 'id': videoId, 'date': date, 'daysRange': daysRange, 'videoToRemove': videoToRemove}; final ids = await _onOperationExecution(type: type, parameters: p); return ids.map((e) => YoutubeID(id: e, playlistID: null)); @@ -100,10 +99,9 @@ class NamidaYTGenerator extends NamidaGeneratorBase with Port IsolateFunctionReturnBuild isolateFunction(SendPort port) { final playlists = {for (final pl in YoutubePlaylistController.inst.playlistsMap.value.values) pl.name: pl.tracks}; final params = { - 'tempStreamInfo': YoutubeController.inst.tempVideoInfosFromStreams, - 'dirStreamInfo': AppDirs.YT_METADATA_TEMP, - 'dirVideoInfo': AppDirs.YT_METADATA, - 'tempBackupYTVH': YoutubeController.inst.tempBackupVideoInfo, + 'databasesDir': AppDirs.YOUTIPIE_CACHE, + 'sensitiveDataDir': AppDirs.YOUTIPIE_DATA, + 'tempBackupYTVH': YoutubeInfoController.utils.tempBackupVideoInfo, 'mostplayedPlaylist': YoutubeHistoryController.inst.topTracksMapListens.keys, 'favouritesPlaylist': YoutubePlaylistController.inst.favouritesPlaylist.value.tracks, 'playlists': playlists, @@ -114,17 +112,17 @@ class NamidaYTGenerator extends NamidaGeneratorBase with Port } static void _prepareResourcesAndListen(Map params) async { - final tempStreamInfo = params['tempStreamInfo'] as Map; - final dirStreamInfo = params['dirStreamInfo'] as String; - final dirVideoInfo = params['dirVideoInfo'] as String; + final databasesDir = params['databasesDir'] as String; + final sensitiveDataDir = params['sensitiveDataDir'] as String; final tempBackupYTVH = params['tempBackupYTVH'] as Map; + final mostplayedPlaylist = params['mostplayedPlaylist'] as Iterable; final favouritesPlaylist = params['favouritesPlaylist'] as List; final playlists = params['playlists'] as Map>; final sendPort = params['sendPort'] as SendPort; final token = params['token'] as RootIsolateToken; - final recievePort = ReceivePort(); + final recievePort = ReceivePort(); BackgroundIsolateBinaryMessenger.ensureInitialized(token); sendPort.send(recievePort.sendPort); @@ -133,11 +131,16 @@ class NamidaYTGenerator extends NamidaGeneratorBase with Port final allIds = []; final allIdsAdded = {}; + var lookupListStreamInfoMapCacheDetails = []; + var lookupListVideoStreamsMapCacheDetails = []; + // -- start listening StreamSubscription? streamSub; streamSub = recievePort.listen((p) async { if (PortsProvider.isDisposeMessage(p)) { recievePort.close(); + lookupListStreamInfoMapCacheDetails.loop((item) => item.close()); + lookupListVideoStreamsMapCacheDetails.loop((item) => item.close()); releaseDateMap.clear(); allIds.clear(); allIdsAdded.clear(); @@ -183,51 +186,59 @@ class NamidaYTGenerator extends NamidaGeneratorBase with Port // -- start filling info final start = DateTime.now(); - for (final id in tempStreamInfo.keys) { - allIds.add(id); - allIdsAdded[id] = true; - releaseDateMap[id] = tempStreamInfo[id]?.date; - } - - final completer1 = Completer(); - final completer2 = Completer(); + YoutiPie.cacheManager.init(databasesDir); + final activeChannel = YoutiPie.getActiveAccountChannelIsolate(sensitiveDataDir); + final activeChannelId = activeChannel?.id; - Directory(dirStreamInfo).listAllIsolate().then((value) { - value.loop((file) { - try { - final res = (file as File).readAsJsonSync(); - if (res != null) { - final id = res['id']; + if (activeChannelId != null && activeChannelId.isNotEmpty) { + lookupListStreamInfoMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.streamInfoItem, null, () => activeChannelId)); + lookupListVideoStreamsMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.videoStreams, null, () => activeChannelId)); + } + // -- damn the annonymous acc videos look saxy + lookupListStreamInfoMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.streamInfoItem, null, () => null)); + lookupListVideoStreamsMapCacheDetails.add(CacheDetailsBase(YoutiPieSection.videoStreams, null, () => null)); + + lookupListStreamInfoMapCacheDetails.loop( + (db) { + db.loadEverything( + (map) { + final id = map['id']; if (id != null && releaseDateMap[id] == null) { + DateTime? date; + try { + date = PublishTime.fromMap(map['publishedAt']).date; + } catch (_) {} allIds.add(id); allIdsAdded[id] = true; - releaseDateMap[id] = (res['date'] as String?)?.getDateTimeFromMSSEString(); + releaseDateMap[id] = date; } - } - } catch (_) {} - }); - completer1.complete(); - }); - Directory(dirVideoInfo).listAllIsolate().then((value) { - value.loop((file) { - try { - final res = (file as File).readAsJsonSync(); - if (res != null) { - final id = res['id']; - if (id != null && releaseDateMap[id] == null) { - allIds.add(id); - allIdsAdded[id] = true; - releaseDateMap[id] = (res['date'] as String?)?.getDateTimeFromMSSEString(); + }, + ); + }, + ); + lookupListVideoStreamsMapCacheDetails.loop((db) { + db.loadEverything( + (map) { + final info = map['info'] as Map; + final id = info['id']; + if (id != null && releaseDateMap[id] == null) { + DateTime? date; + try { + date = PublishTime.fromMap(info['publishDate']).date; + } catch (_) {} + if (date == null) { + try { + date = PublishTime.fromMap(info['uploadDate']).date; + } catch (_) {} } + allIds.add(id); + allIdsAdded[id] = true; + releaseDateMap[id] = date; } - } catch (_) {} - }); - completer2.complete(); + }, + ); }); - await completer1.future; - await completer2.future; - for (final id in tempBackupYTVH.keys) { if (releaseDateMap[id] == null) { allIds.add(id); diff --git a/lib/youtube/controller/yt_miniplayer_ui_controller.dart b/lib/youtube/controller/yt_miniplayer_ui_controller.dart new file mode 100644 index 00000000..4330bcc6 --- /dev/null +++ b/lib/youtube/controller/yt_miniplayer_ui_controller.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/youtube/youtube_miniplayer.dart'; +import 'package:youtipie/core/enum.dart'; + +class YoutubeMiniplayerUiController { + static final inst = YoutubeMiniplayerUiController._(); + YoutubeMiniplayerUiController._(); + + final currentCommentSort = CommentsSortType.top.obs; + + late final ytMiniplayerKey = GlobalKey(); + + void startDimTimer() { + ytMiniplayerKey.currentState?.startDimTimer(); + } + + void cancelDimTimer() { + ytMiniplayerKey.currentState?.cancelDimTimer(); + } + + void resetGlowUnderVideo() { + ytMiniplayerKey.currentState?.resetGlowUnderVideo(); + } +} diff --git a/lib/youtube/functions/add_to_playlist_sheet.dart b/lib/youtube/functions/add_to_playlist_sheet.dart index 43107467..dd136c48 100644 --- a/lib/youtube/functions/add_to_playlist_sheet.dart +++ b/lib/youtube/functions/add_to_playlist_sheet.dart @@ -11,7 +11,7 @@ import 'package:namida/core/utils.dart'; import 'package:namida/main.dart'; import 'package:namida/ui/dialogs/edit_tags_dialog.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart' as pc; import 'package:namida/youtube/youtube_playlists_view.dart'; @@ -29,7 +29,7 @@ void showAddToPlaylistSheet({ final context = ctx ?? rootContext; final videoNamesSubtitle = ids - .map((id) => idsNamesLookup[id] ?? YoutubeController.inst.getVideoName(id) ?? id) // + .map((id) => idsNamesLookup[id] ?? YoutubeInfoController.utils.getVideoName(id) ?? id) // .take(3) .join(', ') + (ids.length > 3 ? '... + ${ids.length - 3}' : ''); diff --git a/lib/youtube/functions/download_sheet.dart b/lib/youtube/functions/download_sheet.dart index f4729ce8..ca4097be 100644 --- a/lib/youtube/functions/download_sheet.dart +++ b/lib/youtube/functions/download_sheet.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.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/controller/settings_controller.dart'; @@ -18,17 +16,22 @@ import 'package:namida/ui/dialogs/edit_tags_dialog.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_item_download_config.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/functions/video_download_options.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/yt_utils.dart'; +import 'package:youtipie/class/streams/audio_stream.dart'; +import 'package:youtipie/class/streams/video_stream.dart'; +import 'package:youtipie/class/streams/video_stream_info.dart'; +import 'package:youtipie/class/streams/video_streams_result.dart'; +import 'package:youtipie/core/extensions.dart' hide ListUtils; /// [onConfirmButtonTap] should return wether to pop sheet or not. Future showDownloadVideoBottomSheet({ BuildContext? ctx, required String videoId, Color? colorScheme, - VideoInfo? info, String confirmButtonText = '', bool Function(String groupName, YoutubeItemDownloadConfig config)? onConfirmButtonTap, bool showSpecificFileOptionsInEditTagDialog = true, @@ -39,10 +42,10 @@ Future showDownloadVideoBottomSheet({ final showAudioWebm = false.obs; final showVideoWebm = false.obs; - final video = Rxn(); - final selectedAudioOnlyStream = Rxn(); - final selectedVideoOnlyStream = Rxn(); - final videoInfo = ValueNotifier(null); + final streamResult = Rxn(); + final selectedAudioOnlyStream = Rxn(); + final selectedVideoOnlyStream = Rxn(); + final videoInfo = Rxn(); final videoOutputFilenameController = TextEditingController(); final videoThumbnail = Rxn(); DateTime? videoDateTime; @@ -52,8 +55,6 @@ Future showDownloadVideoBottomSheet({ String groupName = ''; - videoInfo.value = info; - final tagsMap = {}; void updateTagsMap(Map map) { for (final e in map.entries) { @@ -68,7 +69,7 @@ Future showDownloadVideoBottomSheet({ } if (initialItemConfig != null) return; // cuz already set. - final videoTitle = videoInfo.value?.name ?? videoId; + final videoTitle = videoInfo.value?.title ?? videoId; if (selectedAudioOnlyStream.value == null && selectedVideoOnlyStream.value == null) { videoOutputFilenameController.text = videoTitle; } else { @@ -77,7 +78,7 @@ Future showDownloadVideoBottomSheet({ final filenameRealAudio = videoTitle; videoOutputFilenameController.text = filenameRealAudio; } else { - final filenameRealVideo = "${videoTitle}_${selectedVideoOnlyStream.value?.resolution}"; + final filenameRealVideo = "${videoTitle}_${selectedVideoOnlyStream.value?.qualityLabel}"; videoOutputFilenameController.text = filenameRealVideo; } } @@ -88,34 +89,44 @@ Future showDownloadVideoBottomSheet({ updateTagsMap(initialItemConfig.ffmpegTags); } - YoutubeController.inst.getAvailableStreams(videoId).then((v) { - video.value = v; - videoInfo.value = v.videoInfo; + void onStreamsObtained(VideoStreamsResult? streams) { + streamResult.value = streams; + if (streams?.info != null) videoInfo.value = streams!.info!; - selectedAudioOnlyStream.value = video.value?.audioOnlyStreams?.firstWhereEff((e) => e.formatSuffix != 'webm'); + selectedAudioOnlyStream.value = streamResult.value?.audioStreams.firstNonWebm(); if (selectedAudioOnlyStream.value == null) { - selectedAudioOnlyStream.value = video.value?.audioOnlyStreams?.firstOrNull; - if (selectedAudioOnlyStream.value?.formatSuffix == 'webm') { + selectedAudioOnlyStream.value = streams?.audioStreams.firstOrNull; + if (selectedAudioOnlyStream.value?.isWebm == true) { showAudioWebm.value = true; } } - selectedVideoOnlyStream.value = video.value?.videoOnlyStreams?.firstWhereEff( + selectedVideoOnlyStream.value = streams?.videoStreams.firstWhereEff( (e) { final cached = e.getCachedFile(videoId); if (cached != null) return true; - return e.formatSuffix != 'webm' && - settings.youtubeVideoQualities.contains( - e.resolution?.videoLabelToSettingLabel(), - ); + final strQualityLabel = e.qualityLabel.videoLabelToSettingLabel(); + return !e.isWebm && settings.youtubeVideoQualities.contains(strQualityLabel); }, ) ?? - video.value?.videoOnlyStreams?.firstWhereEff((e) => e.formatSuffix != 'webm'); + streams?.videoStreams.firstWhereEff((e) => !e.isWebm); updatefilenameOutput(); - videoDateTime = videoInfo.value?.date; + videoDateTime = videoInfo.value?.publishedAt.date; final meta = YTUtils.getMetadataInitialMap(videoId, videoInfo.value, autoExtract: settings.ytAutoExtractVideoTagsFromInfo.value); if (initialItemConfig == null) updateTagsMap(meta); - }); + } + + final streamsInCache = YoutubeInfoController.video.fetchVideoStreamsSync(videoId); + if (streamsInCache != null) { + final expired = streamsInCache.hasExpired(); + if (expired == true || expired == null || streamsInCache.audioStreams.isEmpty) { + YoutubeInfoController.video.fetchVideoStreams(videoId).then(onStreamsObtained); + } else { + onStreamsObtained(streamsInCache); + } + } else { + YoutubeInfoController.video.fetchVideoStreams(videoId).then(onStreamsObtained); + } Widget getQualityChipBase({ required final Color? selectedColor, @@ -225,9 +236,11 @@ Future showDownloadVideoBottomSheet({ ), if (subtitle != null) ...[ const SizedBox(width: 8.0), - Text( - '• $subtitle', - style: style ?? context.textTheme.displaySmall, + Expanded( + child: Text( + '• $subtitle', + style: style ?? context.textTheme.displaySmall, + ), ), ], const Spacer(), @@ -272,9 +285,9 @@ Future showDownloadVideoBottomSheet({ width: context.width, child: Padding( padding: const EdgeInsets.all(18.0).add(EdgeInsets.only(bottom: bottomPadding)), - child: ValueListenableBuilder( - valueListenable: videoInfo, - builder: (context, value, child) { + child: ObxO( + rx: videoInfo, + builder: (videoInfo) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -300,31 +313,31 @@ Future showDownloadVideoBottomSheet({ crossAxisAlignment: CrossAxisAlignment.start, children: [ ShimmerWrapper( - shimmerEnabled: videoInfo.value == null, + shimmerEnabled: videoInfo == null, child: NamidaDummyContainer( borderRadius: 6.0, width: context.width, height: 18.0, - shimmerEnabled: videoInfo.value == null, + shimmerEnabled: videoInfo == null, child: Text( - videoInfo.value?.name ?? videoId, + videoInfo?.title ?? videoId, style: context.textTheme.displayMedium, ), ), ), const SizedBox(height: 2.0), ShimmerWrapper( - shimmerEnabled: videoInfo.value == null, + shimmerEnabled: videoInfo == null, child: NamidaDummyContainer( borderRadius: 4.0, width: context.width - 24.0, height: 12.0, - shimmerEnabled: videoInfo.value == null, + shimmerEnabled: videoInfo == null, child: () { - final dateFormatted = videoInfo.value?.date?.millisecondsSinceEpoch.dateFormattedOriginal; + final dateFormatted = videoInfo?.publishedAt.date?.millisecondsSinceEpoch.dateFormattedOriginal; return Text( [ - videoInfo.value?.duration?.inSeconds.secondsLabel ?? "00:00", + videoInfo?.durSeconds?.secondsLabel ?? "00:00", if (dateFormatted != null) dateFormatted, ].join(' - '), style: context.textTheme.displaySmall, @@ -336,67 +349,72 @@ Future showDownloadVideoBottomSheet({ ), ), const SizedBox(width: 6.0), - Obx( - () { - final isWEBM = selectedAudioOnlyStream.valueR?.formatSuffix == 'webm'; - return Stack( - alignment: Alignment.bottomRight, - children: [ - NamidaIconButton( - horizontalPadding: 0.0, - icon: Broken.edit, - onPressed: () { - if (videoInfo.value == null && tagsMap.isEmpty) return; + ObxO( + rx: selectedVideoOnlyStream, + builder: (selectedVideo) => ObxO( + rx: selectedAudioOnlyStream, + builder: (selectedAudio) { + if (selectedAudio == null && selectedVideo == null) return const SizedBox(); + final isWEBM = (selectedAudio?.isWebm == true || selectedVideo?.isWebm == true); + return Stack( + alignment: Alignment.bottomRight, + children: [ + NamidaIconButton( + horizontalPadding: 0.0, + icon: Broken.edit, + onPressed: () { + if (videoInfo == null && tagsMap.isEmpty) return; - // webm doesnt support tag editing - if (isWEBM) { - snackyy( - title: lang.ERROR, - message: lang.WEBM_NO_EDIT_TAGS_SUPPORT, - leftBarIndicatorColor: Colors.red, - margin: EdgeInsets.zero, - borderRadius: 0, - top: false, - ); - } + // webm doesnt support tag editing + if (isWEBM) { + snackyy( + title: lang.ERROR, + message: lang.WEBM_NO_EDIT_TAGS_SUPPORT, + leftBarIndicatorColor: Colors.red, + margin: EdgeInsets.zero, + borderRadius: 0, + top: false, + ); + } - showVideoDownloadOptionsSheet( - context: context, - videoTitle: videoInfo.value?.name, - videoUploader: videoInfo.value?.uploaderName, - tagMaps: tagsMap, - supportTagging: !isWEBM, - showSpecificFileOptions: showSpecificFileOptionsInEditTagDialog, - onDownloadGroupNameChanged: (newGroupName) { - groupName = newGroupName; - formKey.currentState?.validate(); - }, - ); - }, - ), - if (isWEBM) - IgnorePointer( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - boxShadow: [ - BoxShadow( - color: context.theme.scaffoldBackgroundColor, - spreadRadius: 0, - blurRadius: 3.0, - ), - ], - ), - child: const Icon( - Broken.info_circle, - color: Colors.red, - size: 16.0, + showVideoDownloadOptionsSheet( + context: context, + videoTitle: videoInfo?.title, + videoUploader: videoInfo?.channelName, + tagMaps: tagsMap, + supportTagging: !isWEBM, + showSpecificFileOptions: showSpecificFileOptionsInEditTagDialog, + onDownloadGroupNameChanged: (newGroupName) { + groupName = newGroupName; + formKey.currentState?.validate(); + }, + ); + }, + ), + if (isWEBM) + IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: context.theme.scaffoldBackgroundColor, + spreadRadius: 0, + blurRadius: 3.0, + ), + ], + ), + child: const Icon( + Broken.info_circle, + color: Colors.red, + size: 16.0, + ), ), - ), - ) - ], - ); - }, + ) + ], + ); + }, + ), ), ], ), @@ -408,7 +426,7 @@ Future showDownloadVideoBottomSheet({ Obx( () { final e = selectedAudioOnlyStream.valueR; - final subtitle = e == null ? null : "${e.bitrateText} • ${e.formatSuffix} • ${e.sizeInBytes?.fileSizeFormatted}"; + final subtitle = e == null ? null : "${e.bitrateText()} • ${e.codecInfo.container} • ${e.sizeInBytes.fileSizeFormatted}"; return getTextWidget( title: lang.AUDIO, subtitle: subtitle, @@ -416,15 +434,15 @@ Future showDownloadVideoBottomSheet({ onCloseIconTap: () => selectedAudioOnlyStream.value = null, onSussyIconTap: () { showAudioWebm.toggle(); - if (showAudioWebm.value == false && selectedAudioOnlyStream.value?.formatSuffix == 'webm') { - selectedAudioOnlyStream.value = video.value?.audioOnlyStreams?.firstOrNull; + if (showAudioWebm.value == false && selectedAudioOnlyStream.value?.isWebm == true) { + selectedAudioOnlyStream.value = streamResult.value?.audioStreams.firstOrNull; } }, ); }, ), Obx( - () => video.valueR?.audioOnlyStreams == null + () => streamResult.valueR?.audioStreams == null ? Padding( padding: const EdgeInsets.all(8.0), child: ShimmerWrapper( @@ -436,9 +454,7 @@ Future showDownloadVideoBottomSheet({ ), ) : getPopupItem( - items: showAudioWebm.valueR - ? video.valueR!.audioOnlyStreams! - : video.valueR!.audioOnlyStreams!.where((element) => element.formatSuffix != 'webm').toList(), + items: showAudioWebm.valueR ? streamResult.valueR!.audioStreams : streamResult.valueR!.audioStreams.where((element) => !element.isWebm).toList(), itemBuilder: (element) { return Obx( () { @@ -446,8 +462,8 @@ Future showDownloadVideoBottomSheet({ return getQualityButton( selected: selectedAudioOnlyStream.valueR == element, cacheExists: cacheFile != null, - title: "${element.codec} • ${element.sizeInBytes?.fileSizeFormatted}", - subtitle: "${element.formatSuffix} • ${element.bitrateText}", + title: "${element.codecInfo.codec} • ${element.sizeInBytes.fileSizeFormatted}", + subtitle: "${element.codecInfo.container} • ${element.bitrateText()}", onTap: () => selectedAudioOnlyStream.value = element, ); }, @@ -456,10 +472,10 @@ Future showDownloadVideoBottomSheet({ ), ), getDivider(), - Obx( - () { - final e = selectedVideoOnlyStream.valueR; - final subtitle = e == null ? null : "${e.resolution} • ${e.sizeInBytes?.fileSizeFormatted}"; + ObxO( + rx: selectedVideoOnlyStream, + builder: (vostream) { + final subtitle = vostream == null ? null : "${vostream.qualityLabel} • ${vostream.sizeInBytes.fileSizeFormatted}"; return getTextWidget( title: lang.VIDEO, subtitle: subtitle, @@ -467,15 +483,15 @@ Future showDownloadVideoBottomSheet({ onCloseIconTap: () => selectedVideoOnlyStream.value = null, onSussyIconTap: () { showVideoWebm.toggle(); - if (showVideoWebm.value == false && selectedVideoOnlyStream.value?.formatSuffix == 'webm') { - selectedVideoOnlyStream.value = video.value?.videoOnlyStreams?.firstOrNull; + if (showVideoWebm.value == false && selectedVideoOnlyStream.value?.isWebm == true) { + selectedVideoOnlyStream.value = streamResult.value?.videoStreams.firstOrNull; } }, ); }, ), Obx( - () => video.valueR?.videoOnlyStreams == null + () => streamResult.valueR?.videoStreams == null ? Padding( padding: const EdgeInsets.all(8.0), child: ShimmerWrapper( @@ -487,9 +503,7 @@ Future showDownloadVideoBottomSheet({ ), ) : getPopupItem( - items: showVideoWebm.valueR - ? video.valueR!.videoOnlyStreams! - : video.valueR!.videoOnlyStreams!.where((element) => element.formatSuffix != 'webm').toList(), + items: showVideoWebm.valueR ? streamResult.valueR!.videoStreams : streamResult.valueR!.videoStreams.where((element) => !element.isWebm).toList(), itemBuilder: (element) { return Obx( () { @@ -497,8 +511,8 @@ Future showDownloadVideoBottomSheet({ return getQualityButton( selected: selectedVideoOnlyStream.valueR == element, cacheExists: cacheFile != null, - title: "${element.resolution} • ${element.sizeInBytes?.fileSizeFormatted}", - subtitle: "${element.formatSuffix} • ${element.bitrateText}", + title: "${element.qualityLabel} • ${element.sizeInBytes.fileSizeFormatted}", + subtitle: "${element.codecInfo.container} • ${element.bitrateText()}", onTap: () => selectedVideoOnlyStream.value = element, ); }, @@ -598,9 +612,8 @@ Future showDownloadVideoBottomSheet({ fileDate: videoDateTime, videoStream: selectedVideoOnlyStream.value, audioStream: selectedAudioOnlyStream.value, - prefferedVideoQualityID: selectedVideoOnlyStream.value?.id, - prefferedAudioQualityID: selectedAudioOnlyStream.value?.id, - fetchMissingStreams: false, + prefferedVideoQualityID: selectedVideoOnlyStream.value?.itag.toString(), + prefferedAudioQualityID: selectedAudioOnlyStream.value?.itag.toString(), ); if (onConfirmButtonTap != null) { final accept = onConfirmButtonTap(groupName, itemConfig); @@ -645,10 +658,10 @@ Future showDownloadVideoBottomSheet({ void closeStreams() { showAudioWebm.close(); showVideoWebm.close(); - video.close(); + streamResult.close(); selectedAudioOnlyStream.close(); selectedVideoOnlyStream.close(); - videoInfo.dispose(); + videoInfo.close(); videoOutputFilenameController.dispose(); videoThumbnail.close(); filenameExists.close(); diff --git a/lib/youtube/functions/yt_playlist_utils.dart b/lib/youtube/functions/yt_playlist_utils.dart index 5f7fe199..f5033990 100644 --- a/lib/youtube/functions/yt_playlist_utils.dart +++ b/lib/youtube/functions/yt_playlist_utils.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart' as yt; +import 'package:nampack/core/main_utils.dart'; import 'package:playlist_manager/module/playlist_id.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:youtipie/class/result_wrapper/list_wrapper_base.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result_base.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_basic_info.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/player_controller.dart'; @@ -14,7 +19,6 @@ import 'package:namida/core/utils.dart'; import 'package:namida/packages/three_arched_circle.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; import 'package:namida/youtube/pages/yt_playlist_download_subpage.dart'; @@ -73,176 +77,178 @@ extension YoutubePlaylistShare on YoutubePlaylist { } } -extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { - /// Sending a [context] means showing a bottom sheet with progress. - /// - /// Returns wether the fetching process ended successfully, videos are accessible through [streams] getter. +extension PlaylistBasicInfoExt on PlaylistBasicInfo { + /// Videos are accessible through [YoutiPiePlaylistResult.items] getter. Future fetchAllPlaylistStreams({ - required BuildContext? context, + required bool showProgressSheet, + required YoutiPiePlaylistResultBase playlist, VoidCallback? onStart, VoidCallback? onEnd, - bool Function()? canKeepFetching, + void Function(YoutiPieFetchAllRes fetchAllRes)? controller, }) async { - final playlist = this; - if (playlist.streams.length >= playlist.streamCount) return true; + final currentCount = playlist.items.length.obs; + final fetchAllRes = playlist.fetchAll( + onProgress: () { + currentCount.value = playlist.items.length; + }, + ); + if (fetchAllRes == null) return true; // no continuation left + + controller?.call(fetchAllRes); - final currentCount = playlist.streams.length.obs; - final totalCount = playlist.streamCount.obs; + final totalCount = Rxn(playlist.basicInfo.videosCount); const switchAnimationDur = Duration(milliseconds: 600); const switchAnimationDurHalf = Duration(milliseconds: 300); - bool isTotalCountNull() => totalCount.value < 0; + void Function()? popSheet; - if (context != null) { - await Future.delayed(Duration.zero); - showModalBottomSheet( - // ignore: use_build_context_synchronously - context: context, - useRootNavigator: true, - isDismissible: false, - builder: (context) { - final iconSize = context.width * 0.5; - final iconColor = context.theme.colorScheme.onSurface.withOpacity(0.6); - return SizedBox( - width: context.width, - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Obx( - () => AnimatedSwitcher( - key: const Key('circle_switch'), - duration: switchAnimationDurHalf, - child: currentCount.valueR < totalCount.valueR || isTotalCountNull() - ? ThreeArchedCircle( - size: iconSize, - color: iconColor, - ) - : Icon( - key: const Key('tick_switch'), - Broken.tick_circle, - size: iconSize, - color: iconColor, - ), + if (showProgressSheet) { + WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback( + (timeStamp) async { + final rootContext = nampack.rootNavigatorKey.currentContext; + if (rootContext != null) { + popSheet = Navigator.of(rootContext, rootNavigator: true).pop; + await Future.delayed(Duration.zero); + showModalBottomSheet( + // ignore: use_build_context_synchronously + context: rootContext, + useRootNavigator: true, + isDismissible: false, + builder: (context) { + final iconSize = context.width * 0.5; + final iconColor = context.theme.colorScheme.onSurface.withOpacity(0.6); + return SizedBox( + width: context.width, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Obx( + () { + final totalC = totalCount.valueR; + return AnimatedSwitcher( + key: const Key('circle_switch'), + duration: switchAnimationDurHalf, + child: totalC == null || currentCount.valueR < totalC + ? ThreeArchedCircle( + size: iconSize, + color: iconColor, + ) + : Icon( + key: const Key('tick_switch'), + Broken.tick_circle, + size: iconSize, + color: iconColor, + ), + ); + }, + ), + const SizedBox(height: 12.0), + Text( + '${lang.FETCHING}...', + style: context.textTheme.displayLarge, + ), + const SizedBox(height: 8.0), + Obx( + () { + final totalC = totalCount.valueR; + return Text( + '${currentCount.valueR.formatDecimal()}/${totalC == null ? '?' : totalC.formatDecimal()}', + style: context.textTheme.displayLarge, + ); + }, + ), + ], ), ), - const SizedBox(height: 12.0), - Text( - '${lang.FETCHING}...', - style: context.textTheme.displayLarge, - ), - const SizedBox(height: 8.0), - Obx( - () => Text( - '${currentCount.valueR.formatDecimal()}/${isTotalCountNull() ? '?' : totalCount.valueR.formatDecimal()}', - style: context.textTheme.displayLarge, - ), - ), - ], - ), - ), - ); + ); + }, + ); + } }, ); } onStart?.call(); - if (isTotalCountNull() || currentCount.value == 0) { - await YoutubeController.inst.getPlaylistStreams(playlist, forceInitial: currentCount.value == 0); - currentCount.value = playlist.streams.length; - totalCount.value = playlist.streamCount < 0 ? playlist.streams.length : playlist.streamCount; - } + await fetchAllRes.result; - void plsPop() => context?.safePop(); + onEnd?.call(); - void closeRxStreams() { - onEnd?.call(); + if (showProgressSheet) { + await Future.delayed(switchAnimationDur); + popSheet?.call(); + } + Future.delayed( + const Duration(milliseconds: 200), () { currentCount.close(); totalCount.close(); - }.executeDelayed(const Duration(milliseconds: 200)); - } - - // -- if still not fetched - if (isTotalCountNull()) { - plsPop(); - closeRxStreams(); - return false; - } - - bool keepFetching() => canKeepFetching == null ? false : canKeepFetching(); - - while (currentCount.value < totalCount.value) { - if (!keepFetching()) break; - final res = await YoutubeController.inst.getPlaylistStreams(playlist); - if (!keepFetching() || res.isEmpty) break; - currentCount.value = playlist.streams.length; - } - - if (context != null) { - await Future.delayed(switchAnimationDur); - plsPop(); - } + }, + ); - closeRxStreams(); return true; } - Future> fetchAllPlaylistAsYTIDs({required BuildContext? context}) async { + Future> fetchAllPlaylistAsYTIDs({ + required bool showProgressSheet, + required YoutiPiePlaylistResultBase playlistToFetch, + }) async { final playlist = this; - final didFetch = await playlist.fetchAllPlaylistStreams(context: context); + final didFetch = await playlist.fetchAllPlaylistStreams(showProgressSheet: showProgressSheet, playlist: playlistToFetch); if (!didFetch) snackyy(title: lang.ERROR, message: 'error fetching playlist videos'); - return playlist.streams + final plId = PlaylistID(id: this.id); + return playlistToFetch.items .map( (e) => YoutubeID( - id: e.id ?? '', - playlistID: getPlaylistID, + id: e.id, + playlistID: plId, ), ) .toList(); } - PlaylistID? get getPlaylistID { - final plId = id; - return plId == null ? null : PlaylistID(id: plId); - } - - Future showPlaylistDownloadSheet({required BuildContext? context}) async { - final videoIDs = await fetchAllPlaylistAsYTIDs(context: context?.mounted == true ? context : null); + Future showPlaylistDownloadSheet({ + required bool showProgressSheet, + required YoutiPiePlaylistResultBase playlistToFetch, + }) async { + final videoIDs = await fetchAllPlaylistAsYTIDs(showProgressSheet: showProgressSheet, playlistToFetch: playlistToFetch); if (videoIDs.isEmpty) return; final playlist = this; - final infoLookup = {}; - playlist.streams.loop((e) { - infoLookup[e.id ?? ''] = e; - }); + final infoLookup = {}; + playlistToFetch.items.loop((e) => infoLookup[e.id] = e); NamidaNavigator.inst.navigateTo( YTPlaylistDownloadPage( ids: videoIDs.toList(), - playlistName: playlist.name ?? '', + playlistName: playlist.title, infoLookup: infoLookup, ), ); } - List getPopupMenuItems( - BuildContext context, { + List getPopupMenuItems({ + required bool showProgressSheet, + required YoutiPiePlaylistResultBase playlistToFetch, bool displayDownloadItem = true, bool displayShuffle = true, bool displayPlay = true, - yt.YoutubePlaylist? playlistToOpen, + bool displayOpenPlaylist = false, }) { - final countText = streamCount < 0 ? "+25" : streamCount.formatDecimalShort(); + final playlist = this; + final videosCount = playlist.videosCount; + final countText = videosCount == null || videosCount < 0 ? "+25" : videosCount.formatDecimalShort(); final playAfterVid = YTUtils.getPlayerAfterVideo(); + + Future> fetchAllIDs() async => await fetchAllPlaylistAsYTIDs(showProgressSheet: showProgressSheet, playlistToFetch: playlistToFetch); + return [ NamidaPopupItem( icon: Broken.music_playlist, title: lang.ADD_TO_PLAYLIST, onTap: () async { - final playlist = this; - final didFetch = await playlist.fetchAllPlaylistStreams(context: context); + final didFetch = await playlist.fetchAllPlaylistStreams(showProgressSheet: showProgressSheet, playlist: playlistToFetch); if (!didFetch) { snackyy(title: lang.ERROR, message: 'error fetching playlist videos'); return; @@ -250,12 +256,10 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { final ids = []; final info = {}; - playlist.streams.loop((e) { + playlistToFetch.items.loop((e) { final id = e.id; - if (id != null) { - ids.add(id); - info[id] = e.name; - } + ids.add(id); + info[id] = e.title; }); showAddToPlaylistSheet( @@ -268,21 +272,25 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { icon: Broken.share, title: lang.SHARE, onTap: () { - if (url != null) Share.share(url!); + final url = this.buildUrl(); + Share.share(url); }, ), if (displayDownloadItem) NamidaPopupItem( icon: Broken.import, title: lang.DOWNLOAD, - onTap: () => showPlaylistDownloadSheet(context: context), + onTap: () => showPlaylistDownloadSheet( + showProgressSheet: showProgressSheet, + playlistToFetch: playlistToFetch, + ), ), - if (playlistToOpen != null) + if (displayOpenPlaylist) NamidaPopupItem( icon: Broken.export_2, title: lang.OPEN, onTap: () { - NamidaNavigator.inst.navigateTo(YTHostedPlaylistSubpage(playlist: playlistToOpen)); + NamidaNavigator.inst.navigateTo(YTHostedPlaylistSubpage(playlist: playlistToFetch)); }, ), if (displayPlay) @@ -290,7 +298,7 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { icon: Broken.play, title: "${lang.PLAY} ($countText)", onTap: () async { - final videos = await fetchAllPlaylistAsYTIDs(context: context); + final videos = await fetchAllIDs(); if (videos.isEmpty) return; Player.inst.playOrPause(0, videos, QueueSource.others); }, @@ -300,7 +308,7 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { icon: Broken.shuffle, title: "${lang.SHUFFLE} ($countText)", onTap: () async { - final videos = await fetchAllPlaylistAsYTIDs(context: context); + final videos = await fetchAllIDs(); if (videos.isEmpty) return; Player.inst.playOrPause(0, videos, QueueSource.others, shuffle: true); }, @@ -309,7 +317,7 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { icon: Broken.next, title: "${lang.PLAY_NEXT} ($countText)", onTap: () async { - final videos = await fetchAllPlaylistAsYTIDs(context: context); + final videos = await fetchAllIDs(); if (videos.isEmpty) return; Player.inst.addToQueue(videos, insertNext: true); }, @@ -321,7 +329,7 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { subtitle: playAfterVid.name, oneLinedSub: true, onTap: () async { - final videos = await fetchAllPlaylistAsYTIDs(context: context); + final videos = await fetchAllIDs(); if (videos.isEmpty) return; Player.inst.addToQueue(videos, insertAfterLatest: true); }, @@ -330,7 +338,7 @@ extension YoutubePlaylistHostedUtils on yt.YoutubePlaylist { icon: Broken.play_cricle, title: "${lang.PLAY_LAST} ($countText)", onTap: () async { - final videos = await fetchAllPlaylistAsYTIDs(context: context); + final videos = await fetchAllIDs(); if (videos.isEmpty) return; Player.inst.addToQueue(videos, insertNext: false); }, diff --git a/lib/youtube/pages/youtube_page.dart b/lib/youtube/pages/youtube_page.dart index 371b1899..c5374416 100644 --- a/lib/youtube/pages/youtube_page.dart +++ b/lib/youtube/pages/youtube_page.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:namida/core/dimensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; +import 'package:namida/youtube/widgets/yt_playlist_card.dart'; import 'package:namida/youtube/widgets/yt_video_card.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item_short.dart'; +import 'package:youtipie/class/youtipie_feed/yt_feed_base.dart'; class YoutubePage extends StatefulWidget { const YoutubePage({super.key}); @@ -21,19 +27,20 @@ class _YoutubePageState extends State with AutomaticKeepAliveClient @override void initState() { super.initState(); - YoutubeController.inst.prepareHomeFeed(); + YoutubeInfoController.current.prepareFeed(); } @override Widget build(BuildContext context) { super.build(context); - final thumbnailWidth = context.width * 0.36; // card height is dynamic - final thumbnailHeight = thumbnailWidth * 9 / 16; - final thumbnailItemExtent = thumbnailHeight + 8.0 * 2; + + const thumbnailHeight = Dimensions.youtubeThumbnailHeight; + const thumbnailWidth = Dimensions.youtubeThumbnailWidth; + const thumbnailItemExtent = thumbnailHeight + 8.0 * 2; return BackgroundWrapper( - child: Obx( - () { - final homepageFeed = YoutubeController.inst.homepageFeed.valueR; + child: ObxO( + rx: YoutubeController.inst.homepageFeed, + builder: (homepageFeed) { final feed = homepageFeed.isEmpty ? List.filled(10, null) : homepageFeed; if (feed.isNotEmpty && feed.first == null) { @@ -45,10 +52,8 @@ class _YoutubePageState extends State with AutomaticKeepAliveClient itemCount: feed.length, shrinkWrap: true, itemBuilder: (context, index) { - return YoutubeVideoCard( - isImageImportantInCache: false, - video: null, - playlistID: null, + return const YoutubeVideoCardDummy( + shimmerEnabled: true, thumbnailWidth: thumbnailWidth, thumbnailHeight: thumbnailHeight, ); @@ -66,15 +71,38 @@ class _YoutubePageState extends State with AutomaticKeepAliveClient ), ), itemBuilder: (context, i) { - final feedItem = feed[i]; - return YoutubeVideoCard( - key: ValueKey(i), - isImageImportantInCache: false, - video: feedItem is StreamInfoItem ? feedItem : null, - playlistID: null, - thumbnailWidth: thumbnailWidth, - thumbnailHeight: thumbnailHeight, - ); + final item = feed[i]; + + return switch (item.runtimeType) { + const (StreamInfoItem) => YoutubeVideoCard( + key: Key((item as StreamInfoItem).id), + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + isImageImportantInCache: false, + video: item, + playlistID: null, + ), + const (StreamInfoItemShort) => YoutubeShortVideoCard( + key: Key("${(item as StreamInfoItemShort?)?.id}"), + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + short: item as StreamInfoItemShort, + playlistID: null, + ), + const (PlaylistInfoItem) => YoutubePlaylistCard( + key: Key((item as PlaylistInfoItem).id), + playlist: item, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + subtitle: item.subtitle, + playOnTap: true, + ), + _ => const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + ), + }; }, itemCount: feed.length, itemExtent: thumbnailItemExtent, diff --git a/lib/youtube/pages/yt_channel_subpage.dart b/lib/youtube/pages/yt_channel_subpage.dart index 215c8a0e..d1908f14 100644 --- a/lib/youtube/pages/yt_channel_subpage.dart +++ b/lib/youtube/pages/yt_channel_subpage.dart @@ -2,8 +2,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:youtipie/class/channels/channel_page_result.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:namida/base/youtube_channel_controller.dart'; import 'package:namida/class/route.dart'; @@ -20,7 +23,7 @@ import 'package:namida/core/utils.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_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_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'; @@ -33,7 +36,7 @@ class YTChannelSubpage extends StatefulWidget with NamidaRouteWidget { final String channelID; final YoutubeSubscription? sub; - final YoutubeChannel? channel; + final ChannelInfoItem? channel; const YTChannelSubpage({super.key, required this.channelID, this.sub, this.channel}); @override @@ -41,34 +44,40 @@ class YTChannelSubpage extends StatefulWidget with NamidaRouteWidget { } class _YTChannelSubpageState extends YoutubeChannelController { - late final YoutubeSubscription ch = YoutubeSubscriptionsController.inst.getChannel(widget.channelID) ?? + late final YoutubeSubscription ch = YoutubeSubscriptionsController.inst.availableChannels.value[widget.channelID] ?? YoutubeSubscription( channelID: widget.channelID.splitLast('/'), subscribed: false, ); - YoutubeChannel? _channelInfo; + YoutiPieChannelPageResult? _channelInfo; bool _canKeepLoadingMore = false; @override void initState() { channel = ch; - fetchChannelStreams(ch); - final channelUrl = 'https://www.youtube.com/channel/${ch.channelID}'; + final channelInfoCache = YoutubeInfoController.channel.fetchChannelInfoSync(ch.channelID); + if (channelInfoCache != null) { + _channelInfo = channelInfoCache; + fetchChannelStreams(channelInfoCache); + } - _channelInfo = widget.channel ?? YoutubeController.inst.fetchChannelDetailsFromCacheSync(ch.channelID, checkFromStorage: true); // -- always get new info. - YoutubeController.inst.fetchChannelDetails(channelUrl, forceRequest: true).then( + YoutubeInfoController.channel.fetchChannelInfo(channelId: ch.channelID, details: ExecuteDetails.forceRequest()).then( (value) { - if (value != null) setState(() => _channelInfo = value); + if (value != null) { + setState(() => _channelInfo = value); + fetchChannelStreams(value); + } }, ); super.initState(); } - void _onImageTap(BuildContext context, String channelID, String imageUrl, bool isBanner) { + void _onImageTap(BuildContext context, String channelID, String? imageUrl, bool isBanner) { + // TODO(youtipie): a way to navigate through all banners? File? file; if (!isBanner) { file = ThumbnailManager.inst.imageUrlToCacheFile(id: null, url: channelID); @@ -120,11 +129,11 @@ class _YTChannelSubpageState extends YoutubeChannelController const thumbnailWidth = Dimensions.youtubeThumbnailWidth; const 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; + final avatarUrl = _channelInfo?.thumbnails.firstOrNull?.url; + final bannerUrl = (_channelInfo?.banners.firstOrNull ?? _channelInfo?.tvbanners.firstOrNull ?? _channelInfo?.mobileBanners.firstOrNull)?.url; + final subsCount = _channelInfo?.subscribersCount; + final subsCountText = _channelInfo?.subscribersCountText; + final streamsCount = _channelInfo?.videosCount; const bannerHeight = 69.0; return BackgroundWrapper( @@ -142,7 +151,7 @@ class _YTChannelSubpageState extends YoutubeChannelController width: context.width, compressed: false, isImportantInCache: false, - channelUrl: bannerUrl, + customUrl: bannerUrl, borderRadius: 0, displayFallbackIcon: false, height: bannerHeight, @@ -164,8 +173,7 @@ class _YTChannelSubpageState extends YoutubeChannelController key: Key('${channelID}_$avatarUrl'), width: context.width * 0.14, isImportantInCache: true, - channelUrl: avatarUrl, - channelIDForHQImage: ch.channelID, + customUrl: avatarUrl, isCircle: true, compressed: false, ), @@ -180,18 +188,19 @@ class _YTChannelSubpageState extends YoutubeChannelController Padding( padding: const EdgeInsets.only(left: 2.0), child: Text( - _channelInfo?.name ?? ch.title, + _channelInfo?.title ?? ch.title, style: context.textTheme.displayLarge, ), ), const SizedBox(height: 4.0), Text( - subsCount == null - ? '? ${lang.SUBSCRIBERS}' - : [ - subsCount.formatDecimalShort(), - subsCount < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, - ].join(' '), + subsCountText ?? + (subsCount == null + ? '? ${lang.SUBSCRIBERS}' + : [ + subsCount.formatDecimalShort(), + subsCount < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, + ].join(' ')), style: context.textTheme.displayMedium?.copyWith( fontSize: 12.0, ), @@ -202,7 +211,7 @@ class _YTChannelSubpageState extends YoutubeChannelController ), ), const SizedBox(width: 4.0), - YTSubscribeButton(channelIDOrURL: channelID), + YTSubscribeButton(channelID: channelID), const SizedBox(width: 12.0), ], ), @@ -228,7 +237,7 @@ class _YTChannelSubpageState extends YoutubeChannelController onTap: () async { _canKeepLoadingMore = !_canKeepLoadingMore; while (_canKeepLoadingMore && !lastLoadingMoreWasEmpty.value && ConnectivityController.inst.hasConnection) { - await fetchStreamsNextPage(ch); + await fetchStreamsNextPage(); } }, ), @@ -257,7 +266,7 @@ class _YTChannelSubpageState extends YoutubeChannelController const Icon(Broken.video_square, size: 16.0), const SizedBox(width: 4.0), Text( - "${streamsList.length} / ${dummyStreamsCount ? '?' : streamsCount}", + "${streamsList?.length ?? '?'} / ${streamsCount ?? '?'}", style: context.textTheme.displayMedium, ), ], @@ -281,23 +290,23 @@ class _YTChannelSubpageState extends YoutubeChannelController ), const SizedBox(width: 4.0), YTVideosActionBar( - title: _channelInfo?.name ?? ch.title, - url: _channelInfo?.url ?? '', + title: _channelInfo?.title ?? ch.title, + urlBuilder: _channelInfo?.buildUrl, barOptions: const YTVideosActionBarOptions( addToPlaylist: false, playLast: false, ), videosCallback: () => streamsList - .map((e) => YoutubeID( - id: e.id ?? '', + ?.map((e) => YoutubeID( + id: e.id, playlistID: null, )) .toList(), infoLookupCallback: () { + final streamsList = this.streamsList; + if (streamsList == null) return null; final m = {}; - streamsList.loop((e) { - m[e.id ?? ''] = e; - }); + streamsList.loop((e) => m[e.id] = e); return m; }, ), @@ -314,12 +323,10 @@ class _YTChannelSubpageState extends YoutubeChannelController child: ListView.builder( itemCount: 15, itemBuilder: (context, index) { - return const YoutubeVideoCard( + return const YoutubeVideoCardDummy( + shimmerEnabled: true, thumbnailHeight: thumbnailHeight, thumbnailWidth: thumbnailWidth, - isImageImportantInCache: false, - video: null, - playlistID: null, thumbnailWidthPercentage: 0.8, ); }, @@ -328,9 +335,11 @@ class _YTChannelSubpageState extends YoutubeChannelController : LazyLoadListView( scrollController: uploadsScrollController, onReachingEnd: () async { - await fetchStreamsNextPage(ch); + await fetchStreamsNextPage(); }, listview: (controller) { + final streamsList = this.streamsList; + if (streamsList == null || streamsList.isEmpty) return const SizedBox(); return ListView.builder( padding: EdgeInsets.only(bottom: Dimensions.inst.globalBottomPaddingTotalR), controller: controller, @@ -339,7 +348,7 @@ class _YTChannelSubpageState extends YoutubeChannelController itemBuilder: (context, index) { final item = streamsList[index]; return YoutubeVideoCard( - key: Key("${context.hashCode}_${(item).id}"), + key: Key(item.id), thumbnailHeight: thumbnailHeight, thumbnailWidth: thumbnailWidth, isImageImportantInCache: false, diff --git a/lib/youtube/pages/yt_channels_page.dart b/lib/youtube/pages/yt_channels_page.dart index 68daf252..2b12d98e 100644 --- a/lib/youtube/pages/yt_channels_page.dart +++ b/lib/youtube/pages/yt_channels_page.dart @@ -1,7 +1,9 @@ import 'package:calendar_date_picker2/calendar_date_picker2.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/base/youtube_channel_controller.dart'; import 'package:namida/controller/file_browser.dart'; @@ -16,8 +18,8 @@ import 'package:namida/ui/widgets/animated_widgets.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_controller.dart'; import 'package:namida/youtube/controller/youtube_import_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_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'; @@ -31,6 +33,11 @@ class YoutubeChannelsPage extends StatefulWidget { } class _YoutubeChannelsPageState extends YoutubeChannelController { + @override + List? get streamsList => _allStreamsList ?? channelVideoTab?.items; + + List? _allStreamsList; + late final ScrollController _horizontalListController; final _allChannelsStreamsProgress = 0.0.obs; @@ -44,7 +51,7 @@ class _YoutubeChannelsPageState extends YoutubeChannelController _fetchAllChannelsStreams() async { setState(() { isLoadingInitialStreams = true; - streamsList.clear(); + _allStreamsList = []; }); _allChannelsStreamsLoading.value = true; @@ -92,10 +103,10 @@ class _YoutubeChannelsPageState extends YoutubeChannelController streams) { - final lastDate = streams.lastOrNull?.date; + final lastDate = streams.lastOrNull?.publishedAt.date; if (lastDate == null || lastDate.millisecondsSinceEpoch < maxDateBeforeMS) { streams.removeWhere((element) { - final date = element.date; + final date = element.publishedAt.date; return date != null && date.millisecondsSinceEpoch < maxDateBeforeMS; }); return true; @@ -103,23 +114,48 @@ class _YoutubeChannelsPageState extends YoutubeChannelController snackyy(message: msg, isError: true, title: lang.ERROR); + + final executeDetails = ExecuteDetails.forceRequest(); + + int pageFetchErrors = 0; for (int i = 0; i < idsLength; i++) { final channelID = ids[i]; _allChannelsStreamsProgress.value = i / idsLength; - final chStreams = await YoutubeController.inst.getChannelStreams(channelID); - while (!enoughStreams(chStreams)) { - final nextPage = await YoutubeController.inst.getChannelStreamsNextPage(); - if (nextPage.isEmpty) break; - chStreams.addAll(nextPage); + final channelPage = await YoutubeInfoController.channel.fetchChannelInfo(channelId: channelID, details: executeDetails); + if (channelPage == null) { + if (pageFetchErrors < 3) { + reportError('failed to fetch channel page for $channelID'); + continue; + } else { + reportError('failed to fetch channel pages 3 times in row, aborting.'); + break; + } + } else { + pageFetchErrors = 0; + } + final videosTab = channelPage.tabs.getVideosTab(); + if (videosTab == null) { + reportError('failed to fetch video tab for $channelID'); + continue; + } + final videosPage = await YoutubeInfoController.channel.fetchChannelTab(channelId: channelPage.id, tab: videosTab, details: executeDetails); + if (videosPage == null) { + reportError('failed to fetch initial videos for $channelID'); + continue; } - printy('p: $i / $idsLength = ${_allChannelsStreamsProgress.value} =>> ${chStreams.length} videos'); + while (!enoughStreams(videosPage.items)) { + final didFetch = await videosPage.fetchNext(); + if (!didFetch) break; + } + printy('p: $i / $idsLength = ${_allChannelsStreamsProgress.value} =>> ${videosPage.length} videos'); if (channel != null) { _allChannelsStreamsProgress.value = 0.0; _allChannelsStreamsLoading.value = false; return; } YoutubeSubscriptionsController.inst.refreshLastFetchedTime(channelID, saveToStorage: false); - streams.addAll(chStreams); + streams.addAll(videosPage.items); } YoutubeSubscriptionsController.inst.sortByLastFetched(); _allChannelsStreamsProgress.value = 0.0; @@ -129,7 +165,7 @@ class _YoutubeChannelsPageState extends YoutubeChannelController { itemCount: 10, shrinkWrap: true, itemBuilder: (context, index) { - return const YoutubeVideoCard( + return const YoutubeVideoCardDummy( + shimmerEnabled: true, fontMultiplier: 0.9, thumbnailHeight: thumbnailHeight, thumbnailWidth: thumbnailWidth, - isImageImportantInCache: false, - video: null, - playlistID: null, - onTap: null, ); }, ), diff --git a/lib/youtube/pages/yt_playlist_download_subpage.dart b/lib/youtube/pages/yt_playlist_download_subpage.dart index 2098f564..4e148515 100644 --- a/lib/youtube/pages/yt_playlist_download_subpage.dart +++ b/lib/youtube/pages/yt_playlist_download_subpage.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:namida/class/route.dart'; import 'package:namida/controller/current_color.dart'; @@ -13,7 +13,6 @@ import 'package:namida/core/dimensions.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; -import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/main.dart'; @@ -21,6 +20,7 @@ import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_id.dart'; import 'package:namida/youtube/class/youtube_item_download_config.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/functions/download_sheet.dart'; import 'package:namida/youtube/functions/video_download_options.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; @@ -90,7 +90,7 @@ class _YTPlaylistDownloadPageState extends State { } YoutubeItemDownloadConfig _getDummyDownloadConfig(String id) { - final videoTitle = widget.infoLookup[id]?.name ?? YoutubeController.inst.getVideoName(id); + final videoTitle = widget.infoLookup[id]?.title ?? YoutubeInfoController.utils.getVideoName(id); final filename = videoTitle ?? id; return YoutubeItemDownloadConfig( id: id, @@ -101,7 +101,6 @@ class _YTPlaylistDownloadPageState extends State { audioStream: null, prefferedVideoQualityID: null, prefferedAudioQualityID: null, - fetchMissingStreams: true, ); } @@ -111,12 +110,10 @@ class _YTPlaylistDownloadPageState extends State { Future _onEditIconTap({ required String id, - required VideoInfo? info, }) async { await showDownloadVideoBottomSheet( showSpecificFileOptionsInEditTagDialog: false, videoId: id, - info: info, confirmButtonText: lang.CONFIRM, onConfirmButtonTap: (groupName, config) { _configMap[id] = config; @@ -126,8 +123,8 @@ class _YTPlaylistDownloadPageState extends State { } void _showAllConfigDialog(BuildContext context) { - final st = StreamController(); - void rebuildy() => st.add(0); + final st = Rxn(); + void rebuildy() => st.refresh(); const visualDensity = null; @@ -153,9 +150,9 @@ class _YTPlaylistDownloadPageState extends State { onPressed: NamidaNavigator.inst.closeDialog, ), ], - child: StreamBuilder( - stream: st.stream, - builder: (context, snapshot) { + child: ObxO( + rx: st, + builder: (_) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -345,8 +342,8 @@ class _YTPlaylistDownloadPageState extends State { itemCount: widget.ids.length, itemBuilder: (context, index) { final id = widget.ids[index].id; - final info = widget.infoLookup[id]?.toVideoInfo() ?? YoutubeController.inst.getVideoInfo(id); - final duration = info?.duration?.inSeconds.secondsLabel; + final info = widget.infoLookup[id] ?? YoutubeInfoController.utils.getStreamInfoSync(id); + final duration = info?.durSeconds?.secondsLabel; return Obx( () { @@ -409,7 +406,7 @@ class _YTPlaylistDownloadPageState extends State { children: [ const SizedBox(height: 6.0), Text( - info?.name ?? id, + info?.title ?? id, style: context.textTheme.displayMedium?.copyWith(fontSize: 15.0 * _hmultiplier), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -424,7 +421,7 @@ class _YTPlaylistDownloadPageState extends State { ), const SizedBox(width: 2.0), Text( - info?.uploaderName ?? YoutubeController.inst.getVideoChannelName(id) ?? '', + info?.channelName ?? info?.channel.title ?? YoutubeInfoController.utils.getVideoChannelName(id) ?? '', style: context.textTheme.displaySmall?.copyWith(fontSize: 14.0 * _hmultiplier), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -440,7 +437,7 @@ class _YTPlaylistDownloadPageState extends State { horizontalPadding: 0.0, icon: Broken.edit_2, iconSize: 20.0, - onPressed: () => _onEditIconTap(id: id, info: info), + onPressed: () => _onEditIconTap(id: id), ), Checkbox.adaptive( shape: RoundedRectangleBorder( diff --git a/lib/youtube/pages/yt_playlist_subpage.dart b/lib/youtube/pages/yt_playlist_subpage.dart index 0b9f1e1e..e6190e99 100644 --- a/lib/youtube/pages/yt_playlist_subpage.dart +++ b/lib/youtube/pages/yt_playlist_subpage.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; -import 'package:newpipeextractor_dart/models/stream_info_item.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart' as yt; import 'package:playlist_manager/module/playlist_id.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/result_wrapper/list_wrapper_base.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result_base.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/base/youtube_streams_manager.dart'; import 'package:namida/class/route.dart'; @@ -23,8 +27,8 @@ import 'package:namida/ui/pages/subpages/playlist_tracks_subpage.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; import 'package:namida/youtube/functions/yt_playlist_utils.dart'; import 'package:namida/youtube/pages/yt_playlist_download_subpage.dart'; @@ -77,6 +81,7 @@ class YTMostPlayedVideosPage extends StatelessWidget with NamidaRouteWidget { overrideListens: listens, playlistID: const PlaylistID(id: k_PLAYLIST_NAME_MOST_PLAYED), playlistName: '', + canHaveDuplicates: false, ); }, ); @@ -262,7 +267,11 @@ class _YTNormalPlaylistSubpageState extends State { NamidaPopupWrapper( openOnLongPress: false, childrenDefault: () => [ - NamidaPopupItem(icon: Broken.share, title: lang.SHARE, onTap: playlist.shareVideos), + NamidaPopupItem( + icon: Broken.share, + title: lang.SHARE, + onTap: playlist.shareVideos, + ), if (widget.isEditable) ...[ NamidaPopupItem( icon: Broken.edit_2, @@ -332,6 +341,7 @@ class _YTNormalPlaylistSubpageState extends State { builder: (canReorderVideos) => canReorderVideos ? draggingTrigger : const SizedBox(), ); }, + canHaveDuplicates: true, ); }, ), @@ -349,11 +359,11 @@ class _YTNormalPlaylistSubpageState extends State { class YTHostedPlaylistSubpage extends StatefulWidget with NamidaRouteWidget { @override - String? get name => playlist.name; + String? get name => playlist.basicInfo.title; @override RouteType get route => RouteType.YOUTUBE_PLAYLIST_SUBPAGE_HOSTED; - final yt.YoutubePlaylist playlist; + final YoutiPiePlaylistResultBase playlist; const YTHostedPlaylistSubpage({ super.key, @@ -366,7 +376,7 @@ class YTHostedPlaylistSubpage extends StatefulWidget with NamidaRouteWidget { class _YTHostedPlaylistSubpageState extends State with YoutubeStreamsManager { @override - List get streamsList => widget.playlist.streams; + List get streamsList => _playlist.items; @override ScrollController get scrollController => controller; @@ -380,25 +390,23 @@ class _YTHostedPlaylistSubpageState extends State with late final ScrollController controller; final _isLoadingMoreItems = false.obs; - bool _canKeepFetching = false; + YoutiPieFetchAllRes? _currentFetchAllRes; void _scrollListener() async { if (_isLoadingMoreItems.value) return; + if (!_playlist.canFetchNext) return; + if (!controller.hasClients) return; - final fetched = widget.playlist.streams.length; - final total = widget.playlist.streamCount; - // -- mainly a workaround for playlists containing hidden videos - // -- works only for small playlists (<=100 videos). - if (fetched > 0 && fetched <= 100 && total > 0 && total <= 100) return; - final needsToLoadMore = total >= 0 && fetched < total; - if (needsToLoadMore && controller.offset >= controller.position.maxScrollExtent - 400 && !controller.position.outOfRange) { + if (controller.offset >= controller.position.maxScrollExtent - 400 && !controller.position.outOfRange) { await _fetch100Video(); } } + late YoutiPiePlaylistResultBase _playlist; @override void initState() { + _playlist = widget.playlist; super.initState(); controller = ScrollController()..addListener(_scrollListener); _fetch100Video(); @@ -416,17 +424,29 @@ class _YTHostedPlaylistSubpageState extends State with Color? bgColor; Future> _getAllPlaylistVideos() async { - return await widget.playlist.fetchAllPlaylistAsYTIDs(context: context); + return await _playlist.basicInfo.fetchAllPlaylistAsYTIDs(showProgressSheet: true, playlistToFetch: _playlist); } - PlaylistID? get _getPlaylistID { - final plId = widget.playlist.id; - return plId == null ? null : PlaylistID(id: plId); + PlaylistID get _getPlaylistID { + final plId = _playlist.basicInfo.id; + return PlaylistID(id: plId); } Future _fetch100Video() async { _isLoadingMoreItems.value = true; - await YoutubeController.inst.getPlaylistStreams(widget.playlist, forceInitial: widget.playlist.streams.isEmpty); + + try { + if (_playlist.items.isEmpty) { + final playlist = await YoutubeInfoController.playlist.fetchPlaylist( + playlistId: _playlist.basicInfo.id, + details: ExecuteDetails.forceRequest(), + ); + if (playlist != null) _playlist = playlist; + } else { + await _playlist.fetchNext(); + } + } catch (_) {} + trySortStreams(); _isLoadingMoreItems.value = false; setState(() {}); @@ -436,14 +456,29 @@ class _YTHostedPlaylistSubpageState extends State with Widget build(BuildContext context) { const horizontalBigThumbPadding = 12.0; final bigThumbWidth = context.width - horizontalBigThumbPadding * 2; - final playlist = widget.playlist; + final playlist = _playlist; const itemsThumbnailHeight = Dimensions.youtubeThumbnailHeight; const itemsThumbnailWidth = Dimensions.youtubeThumbnailWidth; const itemsThumbnailItemExtent = itemsThumbnailHeight + 8.0 * 2; - final firstID = playlist.streams.firstOrNull?.id; - final hasMoreStreamsLeft = playlist.streams.length < playlist.streamCount; + final videosCount = playlist.basicInfo.videosCount; + String? description; + String uploaderTitleAndViews = ''; + String? thumbnailUrl; + if (playlist is YoutiPiePlaylistResult) { + description = playlist.info.description; + final uploaderTitle = playlist.info.uploader?.title; + final viewsCount = playlist.info.viewsCount; + final viewsCountText = viewsCount == null ? playlist.info.viewsCountText : "${viewsCount.formatDecimalShort()} ${viewsCount == 0 ? lang.VIEW : lang.VIEWS}"; + uploaderTitleAndViews = [ + if (uploaderTitle != null) uploaderTitle, + if (viewsCountText != null) viewsCountText, + ].join(' - '); + thumbnailUrl = playlist.info.thumbnails.pick()?.url; + } + final firstID = playlist.items.firstOrNull?.id; + final hasMoreStreamsLeft = playlist.canFetchNext; return AnimatedTheme( duration: const Duration(milliseconds: 300), data: AppThemes.inst.getAppTheme(bgColor, !context.isDarkMode), @@ -462,7 +497,7 @@ class _YTHostedPlaylistSubpageState extends State with height: context.width * 9 / 16, compressed: true, isImportantInCache: false, - channelUrl: playlist.thumbnailUrl, + customUrl: thumbnailUrl, videoId: firstID, blur: 0.0, borderRadius: 0.0, @@ -495,7 +530,7 @@ class _YTHostedPlaylistSubpageState extends State with height: (bigThumbWidth * 9 / 16), compressed: false, isImportantInCache: true, - channelUrl: playlist.thumbnailUrl, + customUrl: thumbnailUrl, videoId: firstID, blur: 4.0, ), @@ -507,23 +542,25 @@ class _YTHostedPlaylistSubpageState extends State with crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - playlist.name ?? '', + playlist.basicInfo.title, style: context.textTheme.displayLarge, ), const SizedBox(height: 6.0), Text( - playlist.streamCount < 0 ? '+25' : playlist.streamCount.displayVideoKeyword, - style: context.textTheme.displaySmall, - ), - const SizedBox(height: 2.0), - Text( - playlist.uploaderName ?? '', + videosCount == null ? '+25' : videosCount.displayVideoKeyword, style: context.textTheme.displaySmall, ), - if (playlist.description != null && playlist.description != '') ...[ + if (uploaderTitleAndViews.isNotEmpty == true) ...[ + const SizedBox(height: 2.0), + Text( + uploaderTitleAndViews, + style: context.textTheme.displaySmall, + ), + ], + if (description != null && description.isNotEmpty) ...[ const SizedBox(height: 2.0), Text( - playlist.description!, + description, style: context.textTheme.displaySmall, ), ], @@ -556,7 +593,7 @@ class _YTHostedPlaylistSubpageState extends State with NamidaNavigator.inst.navigateTo( YTPlaylistDownloadPage( ids: videos, - playlistName: playlist.name ?? '', + playlistName: playlist.basicInfo.title, infoLookup: const {}, ), ); @@ -564,8 +601,9 @@ class _YTHostedPlaylistSubpageState extends State with ), NamidaPopupWrapper( openOnLongPress: false, - childrenDefault: () => playlist.getPopupMenuItems( - context, + childrenDefault: () => playlist.basicInfo.getPopupMenuItems( + playlistToFetch: _playlist, + showProgressSheet: true, displayDownloadItem: false, displayShuffle: false, ), @@ -596,24 +634,29 @@ class _YTHostedPlaylistSubpageState extends State with Expanded( child: sortWidget, ), - Obx( - () => NamidaInkWellButton( + ObxO( + rx: _isLoadingMoreItems, + builder: (isLoadingMoreItems) => NamidaInkWellButton( animationDurationMS: 100, sizeMultiplier: 0.95, borderRadius: 8.0, icon: Broken.task_square, text: lang.LOAD_ALL, - enabled: !_isLoadingMoreItems.valueR && hasMoreStreamsLeft, + enabled: !isLoadingMoreItems && hasMoreStreamsLeft, disableWhenLoading: false, showLoadingWhenDisabled: hasMoreStreamsLeft, onTap: () async { - _canKeepFetching = !_canKeepFetching; - widget.playlist.fetchAllPlaylistStreams( - context: null, - onStart: () => _isLoadingMoreItems.value = true, - onEnd: () => _isLoadingMoreItems.value = false, - canKeepFetching: () => _canKeepFetching, - ); + if (_currentFetchAllRes != null) { + _currentFetchAllRes?.cancel(); + } else { + _playlist.basicInfo.fetchAllPlaylistStreams( + playlist: _playlist, + showProgressSheet: false, + onStart: () => _isLoadingMoreItems.value = true, + onEnd: () => _isLoadingMoreItems.value = false, + controller: (fetchAllRes) => _currentFetchAllRes = fetchAllRes, + ); + } }, ), ), @@ -623,9 +666,9 @@ class _YTHostedPlaylistSubpageState extends State with ), sliver: SliverFixedExtentList.builder( itemExtent: itemsThumbnailItemExtent, - itemCount: playlist.streams.length, + itemCount: playlist.items.length, itemBuilder: (context, index) { - final item = playlist.streams[index]; + final item = playlist.items[index]; return YoutubeVideoCard( thumbnailHeight: itemsThumbnailHeight, thumbnailWidth: itemsThumbnailWidth, @@ -639,8 +682,9 @@ class _YTHostedPlaylistSubpageState extends State with ), ), SliverToBoxAdapter( - child: Obx( - () => _isLoadingMoreItems.valueR + child: ObxO( + rx: _isLoadingMoreItems, + builder: (isLoadingMoreItems) => isLoadingMoreItems ? const Padding( padding: EdgeInsets.all(8.0), child: Stack( diff --git a/lib/youtube/pages/yt_search_results_page.dart b/lib/youtube/pages/yt_search_results_page.dart index af0781e4..ebd2112b 100644 --- a/lib/youtube/pages/yt_search_results_page.dart +++ b/lib/youtube/pages/yt_search_results_page.dart @@ -1,5 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:namida/controller/scroll_search_controller.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/result_wrapper/search_result.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item_short.dart'; import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/current_color.dart'; @@ -12,7 +18,7 @@ import 'package:namida/core/utils.dart'; import 'package:namida/packages/three_arched_circle.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_local_search_controller.dart'; import 'package:namida/youtube/pages/yt_local_search_results.dart'; import 'package:namida/youtube/widgets/yt_channel_card.dart'; @@ -35,9 +41,10 @@ class YoutubeSearchResultsPageState extends State with String get currentSearchText => _latestSearched ?? widget.searchText; String? _latestSearched; - final _searchResult = []; + YoutiPieSearchResult? _searchResult; final _isFetchingMoreResults = false.obs; bool? _loadingFirstResults; + bool? _cachedSearchResults; List get _searchResultsLocal => YTLocalSearchController.inst.searchResults; @@ -76,33 +83,35 @@ class YoutubeSearchResultsPageState extends State with newSearch, maxResults: NamidaNavigator.inst.isytLocalSearchInFullPage ? null : _maxSearchResultsMini, ); - _searchResult.clear(); + if (_searchResult != null) refreshState(() => _searchResult = null); if (newSearch == '') return; - if (!ConnectivityController.inst.hasConnection) return; if (NamidaNavigator.inst.isytLocalSearchInFullPage) return; - if (mounted) { - setState(() { - _loadingFirstResults = true; - }); + refreshState(() => _loadingFirstResults = true); + + YoutiPieSearchResult? result; + if (ConnectivityController.inst.hasConnection) { + result = await YoutubeInfoController.search.search(newSearch, details: ExecuteDetails.forceRequest()); + _cachedSearchResults = false; + } else { + result = YoutubeInfoController.search.searchSync(newSearch); + _cachedSearchResults = result != null; } - final result = await YoutubeController.inst.searchForItems(newSearch); - _searchResult.addAll(result); + + _searchResult = result; _loadingFirstResults = false; - if (mounted) setState(() {}); + refreshState(); } Future _fetchSearchNextPage() async { - if (_searchResult.isEmpty) return; // return if still fetching first results. + final searchRes = _searchResult; + if (searchRes == null) return; // return if still fetching first results. + if (!searchRes.canFetchNext) return; if (!ConnectivityController.inst.hasConnection) return; _isFetchingMoreResults.value = true; - final result = await YoutubeController.inst.searchNextPage(); + await searchRes.fetchNext(); _isFetchingMoreResults.value = false; - if (mounted) { - setState(() { - _searchResult.addAll(result); - }); - } + refreshState(); } @override @@ -115,6 +124,9 @@ class YoutubeSearchResultsPageState extends State with const thumbnailWidthLocal = thumbnailWidth * localMultiplier; const thumbnailHeightLocal = thumbnailHeight * localMultiplier; const thumbnailItemExtentLocal = thumbnailItemExtent * localMultiplier; + + final searchResult = _searchResult; + return BackgroundWrapper( child: Navigator( key: NamidaNavigator.inst.ytLocalSearchNavigatorKey, @@ -162,6 +174,10 @@ class YoutubeSearchResultsPageState extends State with ), const Spacer(), const SizedBox(width: 6.0), + if (_cachedSearchResults == true) ...[ + const SizedBox(width: 6.0), + const Icon(Broken.global_refresh, size: 20.0), + ], ObxO( rx: YTLocalSearchController.inst.didLoadLookupLists, builder: (didLoadLookupLists) => didLoadLookupLists == false ? const LoadingIndicator() : const SizedBox(), @@ -183,6 +199,7 @@ class YoutubeSearchResultsPageState extends State with thumbnailWidthPercentage: 0.6, thumbnailHeight: thumbnailHeightLocal, thumbnailWidth: thumbnailWidthLocal, + dateInsteadOfChannel: true, isImageImportantInCache: false, video: item, playlistID: null, @@ -200,52 +217,134 @@ class YoutubeSearchResultsPageState extends State with ), ), - // -- yt + // -- yt (header) + if (searchResult != null && searchResult.correctedQuery.isNotEmpty) + SliverToBoxAdapter( + child: Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Material( + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + child: InkWell( + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + onTap: () { + final correctedQuery = searchResult.correctedQuery.map((c) => c.text).join(); + ScrollSearchController.inst.searchTextEditingController.text = correctedQuery; + fetchSearch(customText: correctedQuery); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), + child: Text.rich( + TextSpan( + text: "${lang.DID_YOU_MEAN}: ", + style: context.textTheme.displaySmall?.copyWith(fontSize: 13.0), + children: searchResult.correctedQuery + .map((c) => TextSpan( + text: c.text, + style: context.textTheme.displaySmall?.copyWith( + fontSize: 14.0, + fontWeight: c.corrected ? FontWeight.w700 : FontWeight.w500, + ), + )) + .toList(), + ), + ), + ), + ), + ), + ), + ), + ), + + // -- yt (items list) _loadingFirstResults == null ? const SliverToBoxAdapter() : _loadingFirstResults == true ? SliverToBoxAdapter( child: Center( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(32.0), child: ThreeArchedCircle( - color: CurrentColor.inst.color.withOpacity(0.6), - size: context.width * 0.4, + color: CurrentColor.inst.color.withOpacity(0.4), + size: context.width * 0.35, ), ), ), ) - : SliverFixedExtentList.builder( - itemExtent: thumbnailItemExtent, - itemCount: _searchResult.length, - itemBuilder: (context, index) { - final item = _searchResult[index]; - switch (item.runtimeType) { - case const (StreamInfoItem): - return YoutubeVideoCard( - thumbnailHeight: thumbnailHeight, - thumbnailWidth: thumbnailWidth, - isImageImportantInCache: false, - video: item, - playlistID: null, - onTap: widget.onVideoTap == null ? null : () => widget.onVideoTap!(item as StreamInfoItem), + : searchResult == null + ? const SliverToBoxAdapter() + : SliverList.builder( + itemCount: searchResult.length, + itemBuilder: (context, index) { + final chunk = searchResult.items[index]; + final items = chunk.items; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (chunk.title.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + child: Text( + chunk.title, + style: context.textTheme.displayMedium, + ), + ), + SizedBox( + height: items.length * Dimensions.youtubeCardItemExtent, + child: ListView.builder( + primary: false, + 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, + onTap: widget.onVideoTap == null ? null : () => widget.onVideoTap!(item), + ), + const (StreamInfoItemShort) => YoutubeShortVideoCard( + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + short: item as StreamInfoItemShort, + playlistID: null, + ), + const (PlaylistInfoItem) => + // (item as PlaylistInfoItem).isMix + // ? YoutubePlaylistCardMix( + // firstVideoID: firstItem.id, + // title: firstItem.title, + // subtitle: chunk.title, + // ) + // : + YoutubePlaylistCard( + playlist: item as PlaylistInfoItem, + subtitle: item.subtitle.isNotEmpty ? item.subtitle : item.initialVideos.firstOrNull?.title, + ), + const (ChannelInfoItem) => YoutubeChannelCard( + channel: item as ChannelInfoItem, + subscribersCount: null, + thumbnailSize: context.width * 0.18, + ), + _ => const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailHeight: thumbnailHeight, + thumbnailWidth: thumbnailWidth, + ), + }; + }, + ), + ), + if (chunk.title.isNotEmpty) const SizedBox(height: 8.0), + ], ); - case const (YoutubePlaylist): - return YoutubePlaylistCard( - playlist: item, - playOnTap: false, - thumbnailHeight: thumbnailHeight, - thumbnailWidth: thumbnailWidth, - ); - case const (YoutubeChannel): - return YoutubeChannelCard( - channel: item, - thumbnailSize: context.width * 0.18, - ); - } - return const SizedBox(); - }, - ), + }, + ), SliverToBoxAdapter( child: Obx( () => _isFetchingMoreResults.valueR diff --git a/lib/youtube/widgets/yt_card.dart b/lib/youtube/widgets/yt_card.dart index c771655d..ab1679e3 100644 --- a/lib/youtube/widgets/yt_card.dart +++ b/lib/youtube/widgets/yt_card.dart @@ -71,7 +71,7 @@ class YoutubeCard extends StatelessWidget { final thumbnailHeight = this.thumbnailHeight ?? (thumbnailWidthPercentage * Dimensions.youtubeThumbnailHeight); final thumbnailWidth = this.thumbnailWidth ?? (isCircle ? thumbnailHeight : thumbnailHeight * 16 / 9); - final channelThumbSize = 20.0 * thumbnailWidthPercentage; + final channelThumbSize = 0.25 * thumbnailHeight * thumbnailWidthPercentage; return Padding( padding: const EdgeInsets.symmetric(vertical: verticalPadding * 0.5, horizontal: 8.0), @@ -94,7 +94,7 @@ class YoutubeCard extends StatelessWidget { key: Key("${videoId}_$thumbnailUrl"), isImportantInCache: isImageImportantInCache, videoId: videoId, - channelUrl: thumbnailUrl, + customUrl: thumbnailUrl, width: thumbnailWidth, height: thumbnailHeight, borderRadius: 10.0, @@ -149,17 +149,18 @@ class YoutubeCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (displayChannelThumbnail) ...[ - NamidaDummyContainer( - width: channelThumbSize, - height: channelThumbSize, - shimmerEnabled: shimmerEnabled && (channelThumbnailUrl == null || !displayChannelThumbnail), - child: YoutubeThumbnail( - key: Key("${channelThumbnailUrl}_$channelID"), - isImportantInCache: false, - channelUrl: channelThumbnailUrl ?? '', - channelIDForHQImage: channelThumbnailUrl == null ? (channelID ?? '') : '', + Flexible( + child: NamidaDummyContainer( width: channelThumbSize, - isCircle: true, + height: channelThumbSize, + shimmerEnabled: shimmerEnabled && (channelThumbnailUrl == null || !displayChannelThumbnail), + child: YoutubeThumbnail( + key: Key("${channelThumbnailUrl}_$channelID"), + isImportantInCache: false, + customUrl: channelThumbnailUrl, + width: channelThumbSize, + isCircle: true, + ), ), ), const SizedBox(width: 6.0), diff --git a/lib/youtube/widgets/yt_channel_card.dart b/lib/youtube/widgets/yt_channel_card.dart index 47be028f..e92babaa 100644 --- a/lib/youtube/widgets/yt_channel_card.dart +++ b/lib/youtube/widgets/yt_channel_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/youtipie_feed/channel_info_item.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/core/extensions.dart'; @@ -11,9 +12,15 @@ import 'package:namida/youtube/widgets/yt_shimmer.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; class YoutubeChannelCard extends StatefulWidget { - final YoutubeChannel? channel; + final ChannelInfoItem? channel; + final int? subscribersCount; final double? thumbnailSize; - const YoutubeChannelCard({super.key, required this.channel, this.thumbnailSize}); + const YoutubeChannelCard({ + super.key, + required this.channel, + required this.subscribersCount, + this.thumbnailSize, + }); @override State createState() => _YoutubeChannelCardState(); @@ -24,11 +31,12 @@ class _YoutubeChannelCardState extends State { @override Widget build(BuildContext context) { final channel = widget.channel; - final subscribers = channel?.subscriberCount?.formatDecimalShort(); + final subscribers = widget.subscribersCount?.formatDecimalShort(); final thumbnailSize = widget.thumbnailSize ?? context.width * 0.2; const verticalPadding = 8.0; final shimmerEnabled = channel == null; - final avatarUrl = channel?.avatarUrl ?? channel?.thumbnailUrl; + final avatarUrl = channel?.thumbnails.pick()?.url; + const int? streamsCount = null; // not available with outside [YoutiPieChannelPageResult] return NamidaInkWell( margin: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), bgColor: bgColor?.withAlpha(100) ?? context.theme.cardColor, @@ -50,8 +58,7 @@ class _YoutubeChannelCardState extends State { key: Key("${avatarUrl}_${channel?.id}"), compressed: false, isImportantInCache: true, - channelUrl: avatarUrl, - channelIDForHQImage: channel?.id ?? '', + customUrl: avatarUrl, width: thumbnailSize, height: thumbnailSize, borderRadius: 10.0, @@ -76,7 +83,7 @@ class _YoutubeChannelCardState extends State { borderRadius: 4.0, shimmerEnabled: shimmerEnabled, child: Text( - channel?.name ?? '', + channel?.title ?? '', style: context.textTheme.displayLarge, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -95,7 +102,7 @@ class _YoutubeChannelCardState extends State { overflow: TextOverflow.ellipsis, ), ), - if (channel?.streamCount != null && channel?.streamCount != -1) ...[ + if (streamsCount != null) ...[ const SizedBox(height: 2.0), NamidaDummyContainer( width: context.width, @@ -103,7 +110,7 @@ class _YoutubeChannelCardState extends State { borderRadius: 4.0, shimmerEnabled: shimmerEnabled, child: Text( - channel?.streamCount.displayVideoKeyword ?? '', + streamsCount.displayVideoKeyword, style: context.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w300), maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/lib/youtube/widgets/yt_comment_card.dart b/lib/youtube/widgets/yt_comment_card.dart index 3d42c2b0..ca170123 100644 --- a/lib/youtube/widgets/yt_comment_card.dart +++ b/lib/youtube/widgets/yt_comment_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:selectable_autolink_text/selectable_autolink_text.dart'; +import 'package:youtipie/class/comments/comment_info_item.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/player_controller.dart'; @@ -11,8 +11,6 @@ import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/core/utils.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; -import 'package:namida/youtube/controller/youtube_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/namida_read_more.dart'; import 'package:namida/youtube/widgets/yt_shimmer.dart'; @@ -20,25 +18,29 @@ import 'package:namida/youtube/widgets/yt_thumbnail.dart'; class YTCommentCard extends StatelessWidget { final EdgeInsetsGeometry? margin; - final YoutubeComment? comment; + final CommentInfoItem? comment; const YTCommentCard({super.key, required this.comment, required this.margin}); @override Widget build(BuildContext context) { - final uploaderAvatar = comment?.uploaderAvatarUrl; - final author = comment?.author; - final uploadedFrom = comment?.uploadDate; - final commentText = comment?.commentText; - final likeCount = comment?.likeCount; - final repliesCount = comment?.replyCount == -1 ? null : comment?.replyCount; - final isHearted = comment?.hearted ?? false; - final isPinned = comment?.pinned ?? false; + final uploaderAvatar = comment?.authorAvatarUrl ?? comment?.author?.avatarThumbnailUrl; + final author = comment?.author?.displayName; + final isArtist = comment?.author?.isArtist ?? false; + final uploadedFrom = comment?.publishedTimeText; + final commentTextParsed = comment?.text; + final likeCount = comment?.likesCount; + final repliesCount = comment?.repliesCount; + final isHearted = comment?.isHearted ?? false; + final isPinned = comment?.isPinned ?? false; final containerColor = context.theme.cardColor.withAlpha(100); final readmoreColor = context.theme.colorScheme.primary.withAlpha(160); - final cid = comment?.commentId; - + final authorTextColor = context.theme.colorScheme.onSurface.withAlpha(180); + final authorTextStyle = context.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w400, + color: authorTextColor, + ); return Stack( children: [ Padding( @@ -71,7 +73,7 @@ class YTCommentCard extends StatelessWidget { child: YoutubeThumbnail( key: Key(uploaderAvatar ?? ''), isImportantInCache: false, - channelUrl: uploaderAvatar, + customUrl: uploaderAvatar, width: 38.0, isCircle: true, ), @@ -108,33 +110,50 @@ class YTCommentCard extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - [ - author, - if (uploadedFrom != null) uploadedFrom, - ].join(' • '), - style: context.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w400, color: context.theme.colorScheme.onSurface.withAlpha(180)), + child: Row( + children: [ + if (author != null) + Flexible( + child: Text( + author, + style: authorTextStyle, + ), + ), + if (uploadedFrom != null) + Text( + " • $uploadedFrom", + style: authorTextStyle, + ), + if (isArtist) ...[ + const SizedBox(width: 4.0), + Icon( + Broken.musicnote, + size: 10.0, + color: authorTextColor, + ), + ], + if (isHearted) ...[ + const SizedBox(width: 4.0), + const Icon( + Broken.heart_tick, + size: 14.0, + color: Color.fromARGB(210, 233, 80, 112), + ), + ], + ], ), ), - if (isHearted) ...[ - const SizedBox(width: 4.0), - const Icon( - Broken.heart_tick, - size: 16.0, - color: Color.fromARGB(200, 250, 90, 80), - ), - ], ], ), ), const SizedBox(height: 4.0), AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: commentText == null + child: commentTextParsed == null ? Column( children: [ ...List.filled( - 3, + (4 - 1).getRandomNumberBelow(1), const Padding( padding: EdgeInsets.only(top: 2.0), child: NamidaDummyContainer( @@ -149,7 +168,7 @@ class YTCommentCard extends StatelessWidget { ], ) : NamidaReadMoreText( - text: YoutubeController.inst.commentToParsedHtml[cid] ?? commentText, + text: commentTextParsed, lines: 5, builder: (text, lines, isExpanded, exceededMaxLines, toggle) { return Column( @@ -214,7 +233,7 @@ class YTCommentCard extends StatelessWidget { const SizedBox(height: 8.0), Row( children: [ - const Icon(Broken.like_1, size: 16.0), + if (comment != null) const Icon(Broken.like_1, size: 16.0), if (likeCount == null || likeCount > 0) ...[ const SizedBox(width: 4.0), NamidaDummyContainer( @@ -270,9 +289,8 @@ class YTCommentCard extends StatelessWidget { icon: Broken.copy, title: lang.COPY, onTap: () { - final commentHTML = comment?.commentText; - if (commentHTML != null) { - Clipboard.setData(ClipboardData(text: YoutubeController.inst.removeCommentHTML(commentHTML))); + if (commentTextParsed != null) { + Clipboard.setData(ClipboardData(text: commentTextParsed)); } }, ), @@ -280,10 +298,10 @@ class YTCommentCard extends StatelessWidget { icon: Broken.user, title: lang.GO_TO_CHANNEL, onTap: () { - final url = comment?.uploaderUrl; - final chid = YoutubeSubscriptionsController.inst.idOrUrlToChannelID(url); - if (chid == null) return; - NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid)); + final channelId = comment?.author?.channelId; + if (channelId != null) { + NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: channelId)); + } }, ), ], @@ -299,21 +317,19 @@ class YTCommentCard extends StatelessWidget { } class YTCommentCardCompact extends StatelessWidget { - final YoutubeComment? comment; + final CommentInfoItem? comment; const YTCommentCardCompact({super.key, required this.comment}); @override Widget build(BuildContext context) { - final uploaderAvatar = comment?.uploaderAvatarUrl; - final author = comment?.author; - final uploadedFrom = comment?.uploadDate; - final commentText = comment?.commentText; - final likeCount = comment?.likeCount; - final repliesCount = comment?.replyCount == -1 ? null : comment?.replyCount; - final isHearted = comment?.hearted ?? false; - final isPinned = comment?.pinned ?? false; - - final cid = comment?.commentId; + final uploaderAvatar = comment?.authorAvatarUrl ?? comment?.author?.avatarThumbnailUrl; + final author = comment?.author?.displayName; + final uploadedFrom = comment?.publishedTimeText; + final commentTextParsed = comment?.text; + final likeCount = comment?.likesCount; + final repliesCount = comment?.repliesCount; + final isHearted = comment?.isHearted ?? false; + final isPinned = comment?.isPinned ?? false; return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -326,7 +342,7 @@ class YTCommentCardCompact extends StatelessWidget { child: YoutubeThumbnail( key: Key(uploaderAvatar ?? ''), isImportantInCache: false, - channelUrl: uploaderAvatar, + customUrl: uploaderAvatar, width: 28.0, isCircle: true, ), @@ -378,7 +394,7 @@ class YTCommentCardCompact extends StatelessWidget { const SizedBox(height: 2.0), AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: commentText == null + child: commentTextParsed == null ? Column( children: [ ...List.filled( @@ -397,7 +413,7 @@ class YTCommentCardCompact extends StatelessWidget { ], ) : Text( - YoutubeController.inst.commentToParsedHtml[cid] ?? commentText, + commentTextParsed, maxLines: 3, overflow: TextOverflow.ellipsis, style: context.textTheme.displaySmall?.copyWith( @@ -411,7 +427,7 @@ class YTCommentCardCompact extends StatelessWidget { Row( children: [ const SizedBox(width: 4.0), - const Icon(Broken.like_1, size: 12.0), + if (comment != null) const Icon(Broken.like_1, size: 12.0), if (likeCount == null || likeCount > 0) ...[ const SizedBox(width: 4.0), NamidaDummyContainer( diff --git a/lib/youtube/widgets/yt_download_task_item_card.dart b/lib/youtube/widgets/yt_download_task_item_card.dart index 857ed36e..05e50488 100644 --- a/lib/youtube/widgets/yt_download_task_item_card.dart +++ b/lib/youtube/widgets/yt_download_task_item_card.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/core/url_utils.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/navigator_controller.dart'; @@ -23,6 +24,7 @@ import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/class/youtube_id.dart'; import 'package:namida/youtube/class/youtube_item_download_config.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/youtube_ongoing_finished_downloads.dart'; import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; import 'package:namida/youtube/functions/download_sheet.dart'; @@ -117,19 +119,20 @@ class YTDownloadTaskItemCard extends StatelessWidget { void _showInfoDialog( final BuildContext context, final YoutubeItemDownloadConfig item, - final VideoInfo? info, + final StreamInfoItem? info, final String groupName, ) { - final videoTitle = info?.name ?? YoutubeController.inst.getVideoName(item.id) ?? item.id; - final videoSubtitle = info?.uploaderName ?? YoutubeController.inst.getVideoChannelName(item.id) ?? '?'; - final dateMS = info?.date?.millisecondsSinceEpoch; + final videoPage = YoutubeInfoController.video.fetchVideoPageSync(item.id); + final videoTitle = info?.title ?? YoutubeInfoController.utils.getVideoName(item.id) ?? item.id; + final videoSubtitle = info?.channel.title ?? YoutubeInfoController.utils.getVideoChannelName(item.id) ?? '?'; + final dateMS = info?.publishedAt.date?.millisecondsSinceEpoch; final dateText = dateMS?.dateAndClockFormattedOriginal ?? '?'; final dateAgo = dateMS == null ? '' : "\n(${Jiffy.parseFromMillisecondsSinceEpoch(dateMS).fromNow()})"; - final duration = info?.duration?.inSeconds.secondsLabel ?? '?'; + final duration = info?.durSeconds?.secondsLabel ?? '?'; final descriptionWidget = info == null ? null : Html( - data: info.description ?? '', + data: info.availableDescription ?? '', style: { '*': Style.fromTextStyle( context.textTheme.displaySmall!.copyWith( @@ -170,6 +173,12 @@ class YTDownloadTaskItemCard extends StatelessWidget { ]; } + final videoId = info?.id ?? ''; + final isUserLiked = YoutubePlaylistController.inst.favouritesPlaylist.value.tracks.firstWhereEff((element) => element.id == videoId) != null; + final videoPageInfo = videoPage?.videoInfo; + final likesCount = videoPageInfo?.engagement?.likesCount; + final videoLikeCount = likesCount == null && !isUserLiked ? null : (isUserLiked ? 1 : 0) + (likesCount ?? 0); + NamidaNavigator.inst.navigateDialog( dialog: CustomBlurryDialog( insetPadding: const EdgeInsets.symmetric(horizontal: 38.0, vertical: 32.0), @@ -178,29 +187,24 @@ class YTDownloadTaskItemCard extends StatelessWidget { trailingWidgets: [ ...getTrailing( Broken.eye, - info?.viewCount?.formatDecimalShort() ?? '?', + videoPageInfo?.viewsCount?.formatDecimalShort() ?? '?', iconColor: context.theme.colorScheme.primary, ), const SizedBox(width: 6.0), - ...() { - final videoId = info?.id ?? ''; - final isUserLiked = YoutubePlaylistController.inst.favouritesPlaylist.value.tracks.firstWhereEff((element) => element.id == videoId) != null; - final videoLikeCount = info?.likeCount == null && !isUserLiked ? null : (isUserLiked ? 1 : 0) + (info?.likeCount ?? 0); - return getTrailing( - Broken.like_1, - videoLikeCount?.formatDecimalShort() ?? '?', - iconWidget: NamidaRawLikeButton( - size: 18.0, - likedIcon: Broken.like_filled, - normalIcon: Broken.like_1, - disabledColor: context.theme.colorScheme.primary, - isLiked: isUserLiked, - onTap: (isLiked) async { - YoutubePlaylistController.inst.favouriteButtonOnPressed(videoId); - }, - ), - ); - }(), + ...getTrailing( + Broken.like_1, + videoLikeCount?.formatDecimalShort() ?? '?', + iconWidget: NamidaRawLikeButton( + size: 18.0, + likedIcon: Broken.like_filled, + normalIcon: Broken.like_1, + disabledColor: context.theme.colorScheme.primary, + isLiked: isUserLiked, + onTap: (isLiked) async { + YoutubePlaylistController.inst.favouriteButtonOnPressed(videoId); + }, + ), + ), ], child: SizedBox( height: context.height * 0.7, @@ -233,7 +237,7 @@ class YTDownloadTaskItemCard extends StatelessWidget { ), TrackInfoListTile( title: lang.LINK, - value: YoutubeController.inst.getYoutubeLink(item.id), + value: YTUrlUtils.buildVideoUrl(item.id), icon: Broken.link_1, ), TrackInfoListTile( @@ -248,7 +252,7 @@ class YTDownloadTaskItemCard extends StatelessWidget { ), TrackInfoListTile( title: lang.DESCRIPTION, - value: HtmlParser.parseHTML(info?.description ?? '').text, + value: info?.availableDescription ?? '', icon: Broken.message_text_1, child: descriptionWidget, ), @@ -333,9 +337,9 @@ class YTDownloadTaskItemCard extends StatelessWidget { getRow( icon: Broken.video_square, texts: [ - videoStream.sizeInBytes?.fileSizeFormatted ?? '', - videoStream.bitrateText, - videoStream.resolution ?? '', + videoStream.sizeInBytes.fileSizeFormatted, + videoStream.bitrateText(), + videoStream.qualityLabel, ], ), ], @@ -344,8 +348,8 @@ class YTDownloadTaskItemCard extends StatelessWidget { getRow( icon: Broken.audio_square, texts: [ - audioStream.sizeInBytes?.fileSizeFormatted ?? '', - audioStream.bitrateText, + audioStream.sizeInBytes.fileSizeFormatted, + audioStream.bitrateText(), ], ), ], @@ -470,8 +474,8 @@ class YTDownloadTaskItemCard extends StatelessWidget { const thumbHeight = 24.0 * 2.6; const thumbWidth = thumbHeight * 16 / 9; - final info = YoutubeController.inst.getVideoInfo(item.id); - final duration = info?.duration?.inSeconds.secondsLabel; + final info = YoutubeInfoController.utils.getStreamInfoSync(item.id); + final duration = info?.durSeconds?.secondsLabel; final itemIcon = item.videoStream != null ? Broken.video @@ -484,10 +488,10 @@ class YTDownloadTaskItemCard extends StatelessWidget { openOnLongPress: true, childrenDefault: () => YTUtils.getVideoCardMenuItems( videoId: item.id, - url: info?.url, - channelUrl: info?.uploaderUrl, + url: info?.buildUrl(), + channelID: info?.channelId, playlistID: null, - idsNamesLookup: {item.id: info?.name}, + idsNamesLookup: {item.id: info?.title}, playlistName: '', videoYTID: null, )..insert( @@ -732,7 +736,7 @@ class YTDownloadTaskItemCard extends StatelessWidget { children: [ Text( [ - item.videoStream?.resolution, + item.videoStream?.qualityLabel, downloadedFile.fileSizeFormatted(), ].joinText(), style: context.textTheme.displaySmall?.copyWith(fontSize: 11.0), diff --git a/lib/youtube/widgets/yt_history_video_card.dart b/lib/youtube/widgets/yt_history_video_card.dart index 4ab559a0..a096e349 100644 --- a/lib/youtube/widgets/yt_history_video_card.dart +++ b/lib/youtube/widgets/yt_history_video_card.dart @@ -1,5 +1,6 @@ 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:namida/class/track.dart'; @@ -13,8 +14,6 @@ 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_controller.dart'; -import 'package:namida/youtube/controller/youtube_history_controller.dart'; import 'package:namida/youtube/widgets/yt_thumbnail.dart'; import 'package:namida/youtube/yt_utils.dart'; @@ -40,6 +39,8 @@ class YTHistoryVideoCard extends StatelessWidget { final double cardColorOpacity; final double fadeOpacity; final bool isImportantInCache; + final Color? bgColor; + final bool canHaveDuplicates; const YTHistoryVideoCard({ super.key, @@ -64,6 +65,8 @@ class YTHistoryVideoCard extends StatelessWidget { this.cardColorOpacity = 0.75, this.fadeOpacity = 0, this.isImportantInCache = true, + this.bgColor, + required this.canHaveDuplicates, }); @override @@ -73,10 +76,10 @@ class YTHistoryVideoCard extends StatelessWidget { final thumbHeight = thumbnailHeight ?? (minimalCard ? 24.0 * 3.2 : Dimensions.youtubeCardItemHeight); final thumbWidth = minimalCardWidth ?? thumbHeight * 16 / 9; - final info = YoutubeController.inst.getVideoInfo(video.id); - final duration = info?.duration?.inSeconds.secondsLabel; - final videoTitle = info?.name ?? YoutubeController.inst.getVideoName(video.id) ?? video.id; - final videoChannel = info?.uploaderName ?? YoutubeController.inst.getVideoChannelName(video.id); + final info = YoutubeInfoController.utils.getStreamInfoSync(video.id) /* ?? YoutubeInfoController.video.fetchVideoPageSync(video.id) */; + 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 ? '' @@ -101,10 +104,10 @@ class YTHistoryVideoCard extends StatelessWidget { openOnLongPress: openMenuOnLongPress, childrenDefault: () => YTUtils.getVideoCardMenuItems( videoId: video.id, - url: info?.url, - channelUrl: info?.uploaderUrl, + url: info?.buildUrl(), + channelID: info?.channelId ?? info?.channel.id, playlistID: playlistID, - idsNamesLookup: {video.id: info?.name}, + idsNamesLookup: {video.id: info?.title}, playlistName: playlistName, videoYTID: video, ), @@ -116,10 +119,8 @@ class YTHistoryVideoCard extends StatelessWidget { willSleepAfterThis = sleepconfig.enableSleepAfterItems && Player.inst.sleepingItemIndex(sleepconfig.sleepAfterItems, Player.inst.currentIndex.valueR) == index; } - final isCurrentlyPlaying = Player.inst.currentVideoR == video; - final sameDay = day == YoutubeHistoryController.inst.dayOfHighLight.valueR; - final sameIndex = index == YoutubeHistoryController.inst.indexToHighlight.valueR; - final hightlightedColor = sameDay && sameIndex ? context.theme.colorScheme.onSurface.withAlpha(40) : null; + final bool isRightIndex = canHaveDuplicates ? index == Player.inst.currentIndex.valueR : true; + final bool isCurrentlyPlaying = isRightIndex && Player.inst.currentVideoR == video; 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; @@ -215,9 +216,10 @@ class YTHistoryVideoCard extends StatelessWidget { }, height: minimalCard ? null : Dimensions.youtubeCardItemExtent, margin: EdgeInsets.symmetric(horizontal: minimalCard ? 2.0 : 4.0, vertical: Dimensions.youtubeCardItemVerticalPadding), - bgColor: isCurrentlyPlaying - ? (fromPlayerQueue ? CurrentColor.inst.miniplayerColor : CurrentColor.inst.currentColorScheme).withAlpha(140) - : (hightlightedColor ?? context.theme.cardColor.withOpacity(cardColorOpacity)), + bgColor: bgColor ?? + (isCurrentlyPlaying + ? (fromPlayerQueue ? CurrentColor.inst.miniplayerColor : CurrentColor.inst.currentColorScheme).withAlpha(140) + : (context.theme.cardColor.withOpacity(cardColorOpacity))), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.0.multipliedRadius), ), @@ -252,8 +254,8 @@ class YTHistoryVideoCard extends StatelessWidget { child: NamidaPopupWrapper( childrenDefault: () => YTUtils.getVideoCardMenuItems( videoId: video.id, - url: info?.url, - channelUrl: info?.uploaderUrl, + url: info?.buildUrl(), + channelID: info?.channelId ?? info?.channel.id, playlistID: playlistID, idsNamesLookup: {video.id: videoTitle}, ), diff --git a/lib/youtube/widgets/yt_playlist_card.dart b/lib/youtube/widgets/yt_playlist_card.dart index 2c9dc484..57f283ec 100644 --- a/lib/youtube/widgets/yt_playlist_card.dart +++ b/lib/youtube/widgets/yt_playlist_card.dart @@ -1,5 +1,15 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/packages/three_arched_circle.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; +import 'package:youtipie/class/execute_details.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result_base.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_basic_info.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; +import 'package:youtipie/core/enum.dart'; +import 'package:youtipie/youtipie.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/player_controller.dart'; @@ -11,8 +21,10 @@ import 'package:namida/youtube/functions/yt_playlist_utils.dart'; import 'package:namida/youtube/pages/yt_playlist_subpage.dart'; import 'package:namida/youtube/widgets/yt_card.dart'; -class YoutubePlaylistCard extends StatelessWidget { - final YoutubePlaylist? playlist; +/// Playlist info is fetched automatically after 3 seconds of being displayed, or after attempting an action. +class YoutubePlaylistCard extends StatefulWidget { + final PlaylistInfoItem playlist; + final String? subtitle; final double? thumbnailWidth; final double? thumbnailHeight; final bool playOnTap; @@ -20,59 +32,148 @@ class YoutubePlaylistCard extends StatelessWidget { const YoutubePlaylistCard({ super.key, required this.playlist, + required this.subtitle, this.thumbnailWidth, this.thumbnailHeight, this.playOnTap = false, }); - List getMenuItems(BuildContext context, YoutubePlaylist playlist) { + @override + State createState() => _YoutubePlaylistCardState(); +} + +class _YoutubePlaylistCardState extends State { + String? get firstVideoID { + return widget.playlist.initialVideos.firstOrNull?.id; + } + + YoutiPiePlaylistResultBase? playlistToFetch; + Timer? _fetchTimer; + final _isFetching = Rxn(); + + Future _fetchFunction({required bool forceRequest}) { + final executeDetails = forceRequest ? ExecuteDetails.forceRequest() : ExecuteDetails.cache(CacheDecision.cacheOnly); + if (widget.playlist.isMix) { + final videoId = firstVideoID; + if (videoId == null) return Future.value(null); + return YoutubeInfoController.playlist.getMixPlaylist( + videoId: videoId, + mixId: 'RD$videoId', + details: executeDetails, + ); + } else { + return YoutubeInfoController.playlist.fetchPlaylist( + playlistId: widget.playlist.id, + details: executeDetails, + ); + } + } + + Future _forceFetch() async { + _fetchTimer?.cancel(); + _isFetching.value = true; + final value = await _fetchFunction(forceRequest: true); + playlistToFetch = value; + _isFetching.value = false; + } + + Future _fetchInitial() async { + _fetchTimer?.cancel(); + final value = await _fetchFunction(forceRequest: false); + + if (value != null) { + playlistToFetch = value; + } else { + _fetchTimer = Timer(const Duration(seconds: 3), _forceFetch); + } + } + + @override + void initState() { + super.initState(); + _fetchInitial(); + } + + @override + void dispose() { + _fetchTimer?.cancel(); + super.dispose(); + } + + List getMenuItems(PlaylistBasicInfo playlist) { + if (_fetchTimer?.isActive == true || this.playlistToFetch == null) _forceFetch(); + + final playlistToFetch = this.playlistToFetch; + if (playlistToFetch == null) return []; return playlist.getPopupMenuItems( - context, - displayPlay: playOnTap == false, - playlistToOpen: playOnTap ? playlist : null, + playlistToFetch: playlistToFetch, + showProgressSheet: true, + displayPlay: !widget.playOnTap, + displayOpenPlaylist: widget.playOnTap, ); } @override Widget build(BuildContext context) { - final count = playlist?.streamCount; - final countText = count == null || count < 0 ? "+25" : count.formatDecimalShort(); - final thumbnailUrl = playlist?.thumbnailUrl; - final firstVideoID = playlist?.streams.firstOrNull?.id; - final goodVideoID = firstVideoID != null && firstVideoID != ''; + final playlist = widget.playlist; + String countText; + if (playlist.isMix) { + countText = '+25'; + } else { + countText = playlist.videosCount?.formatDecimalShort() ?? '?'; + } + final thumbnailUrl = playlist.thumbnails.pick()?.url; + final firstVideoID = this.firstVideoID; + final goodVideoID = firstVideoID != ''; return NamidaPopupWrapper( openOnTap: false, openOnLongPress: true, - childrenDefault: playlist == null ? null : () => getMenuItems(context, playlist!), + childrenDefault: () => getMenuItems(playlist), child: YoutubeCard( - thumbnailHeight: thumbnailHeight, - thumbnailWidth: thumbnailWidth, + thumbnailHeight: widget.thumbnailHeight, + thumbnailWidth: widget.thumbnailWidth, isPlaylist: true, isImageImportantInCache: false, extractColor: true, borderRadius: 12.0, videoId: goodVideoID ? firstVideoID : null, thumbnailUrl: goodVideoID ? null : thumbnailUrl, - shimmerEnabled: playlist == null, - title: playlist?.name ?? '', - subtitle: playlist?.uploaderName ?? '', + shimmerEnabled: false, + title: playlist.title, + subtitle: widget.subtitle ?? '', thirdLineText: '', onTap: () async { - if (playlist != null) { - if (playOnTap) { - final videos = await playlist!.fetchAllPlaylistAsYTIDs(context: context); - if (videos.isEmpty) return; - Player.inst.playOrPause(0, videos, QueueSource.others); - } else { - NamidaNavigator.inst.navigateTo(YTHostedPlaylistSubpage(playlist: playlist!)); - } + if (_fetchTimer?.isActive == true || this.playlistToFetch == null) _forceFetch(); + + final playlistToFetch = this.playlistToFetch; + if (playlistToFetch == null) return; + if (widget.playOnTap) { + final videos = await playlist.fetchAllPlaylistAsYTIDs(showProgressSheet: true, playlistToFetch: playlistToFetch); + if (videos.isEmpty) return; + Player.inst.playOrPause(0, videos, QueueSource.others); + } else { + NamidaNavigator.inst.navigateTo(YTHostedPlaylistSubpage(playlist: playlistToFetch)); } }, displayChannelThumbnail: false, displaythirdLineText: false, smallBoxText: countText, smallBoxIcon: Broken.play_cricle, - menuChildrenDefault: playlist == null ? null : () => getMenuItems(context, playlist!), + bottomRightWidgets: [ + ObxO( + rx: _isFetching, + builder: (value) { + if (value == true) { + return ThreeArchedCircle( + color: Colors.red.withOpacity(0.4), + size: 12.0, + ); + } + return const SizedBox.shrink(); + }, + ), + ], + menuChildrenDefault: () => getMenuItems(playlist), ), ); } diff --git a/lib/youtube/widgets/yt_queue_chip.dart b/lib/youtube/widgets/yt_queue_chip.dart index 2179b9ed..764c3eb8 100644 --- a/lib/youtube/widgets/yt_queue_chip.dart +++ b/lib/youtube/widgets/yt_queue_chip.dart @@ -13,8 +13,9 @@ import 'package:namida/core/utils.dart'; import 'package:namida/packages/scroll_physics_modified.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_info_controller.dart'; import 'package:namida/youtube/controller/yt_generators_controller.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; import 'package:namida/youtube/pages/yt_playlist_download_subpage.dart'; import 'package:namida/youtube/widgets/yt_history_video_card.dart'; @@ -99,16 +100,17 @@ class YTMiniplayerQueueChipState extends State with Ticke } void _animateSmallToBig() { + final wasAlreadyBig = NamidaNavigator.inst.isQueueSheetOpen; _animate(1, 0); - YoutubeController.inst.startDimTimer(); + YoutubeMiniplayerUiController.inst.startDimTimer(); NamidaNavigator.inst.isQueueSheetOpen = true; _updateCanScrollQueue(true); - WidgetsBinding.instance.addPostFrameCallback((_) => _animateQueueToCurrentTrack()); + if (!wasAlreadyBig) WidgetsBinding.instance.addPostFrameCallback((_) => _animateQueueToCurrentTrack()); } void _animateBigToSmall() { _animate(0, 1); - YoutubeController.inst.startDimTimer(); + YoutubeMiniplayerUiController.inst.startDimTimer(); NamidaYTGenerator.inst.cleanResources(); NamidaNavigator.inst.isQueueSheetOpen = false; } @@ -204,7 +206,7 @@ class YTMiniplayerQueueChipState extends State with Ticke final currentIndex = Player.inst.currentIndex.valueR; final nextItem = Player.inst.currentQueue.valueR.length - 1 >= currentIndex + 1 ? Player.inst.currentQueue.valueR[currentIndex + 1] as YoutubeID : null; - final nextItemName = nextItem == null ? '' : YoutubeController.inst.getVideoName(nextItem.id); + final nextItemName = nextItem == null ? '' : YoutubeInfoController.utils.getVideoName(nextItem.id); final queueLength = Player.inst.currentQueue.valueR.length; return Column( mainAxisSize: MainAxisSize.min, @@ -264,11 +266,11 @@ class YTMiniplayerQueueChipState extends State with Ticke }, onPointerDown: (_) { _updateCanScrollQueue(true); - YoutubeController.inst.cancelDimTimer(); + YoutubeMiniplayerUiController.inst.cancelDimTimer(); }, onPointerUp: (_) { _updateCanScrollQueue(true); - YoutubeController.inst.startDimTimer(); + YoutubeMiniplayerUiController.inst.startDimTimer(); }, child: GestureDetector( behavior: HitTestBehavior.translucent, @@ -388,6 +390,7 @@ class YTMiniplayerQueueChipState extends State with Ticke draggingEnabled: true, draggableThumbnail: true, showMoreIcon: true, + canHaveDuplicates: true, ), ); }, diff --git a/lib/youtube/widgets/yt_subscribe_buttons.dart b/lib/youtube/widgets/yt_subscribe_buttons.dart index 1b5b1d1f..8e28c495 100644 --- a/lib/youtube/widgets/yt_subscribe_buttons.dart +++ b/lib/youtube/widgets/yt_subscribe_buttons.dart @@ -7,16 +7,16 @@ import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; class YTSubscribeButton extends StatelessWidget { - final String? channelIDOrURL; - const YTSubscribeButton({super.key, required this.channelIDOrURL}); + final String? channelID; + const YTSubscribeButton({super.key, required this.channelID}); @override Widget build(BuildContext context) { - return Obx( - () { - final channelID = YoutubeSubscriptionsController.inst.idOrUrlToChannelID(channelIDOrURL); + return ObxO( + rx: YoutubeSubscriptionsController.inst.availableChannels, + builder: (availableChannels) { final disabled = channelID == null; - final subscribed = YoutubeSubscriptionsController.inst.getChannel(channelID ?? '')?.subscribed ?? false; + final subscribed = availableChannels[channelID ?? '']?.subscribed ?? false; return AnimatedOpacity( opacity: disabled ? 0.5 : 1.0, duration: const Duration(milliseconds: 300), @@ -34,8 +34,9 @@ class YTSubscribeButton extends StatelessWidget { ], ), onPressed: () async { - if (channelIDOrURL != null) { - await YoutubeSubscriptionsController.inst.changeChannelStatus(channelIDOrURL!); + final chid = channelID; + if (chid != null) { + await YoutubeSubscriptionsController.inst.toggleChannelSubscription(chid); } }, ), diff --git a/lib/youtube/widgets/yt_thumbnail.dart b/lib/youtube/widgets/yt_thumbnail.dart index b0c8078e..47ee3a0e 100644 --- a/lib/youtube/widgets/yt_thumbnail.dart +++ b/lib/youtube/widgets/yt_thumbnail.dart @@ -16,8 +16,9 @@ import 'package:namida/ui/widgets/artwork.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; class YoutubeThumbnail extends StatefulWidget { - final String? channelUrl; final String? videoId; + final String? customUrl; + final String? urlSymLinkId; final double? height; final double width; final double borderRadius; @@ -35,8 +36,6 @@ class YoutubeThumbnail extends StatefulWidget { final bool compressed; final bool isImportantInCache; final bool preferLowerRes; - final String channelIDForHQImage; - final bool hqChannelImage; final bool isPlaylist; final double? iconSize; final List? boxShadow; @@ -44,8 +43,9 @@ class YoutubeThumbnail extends StatefulWidget { const YoutubeThumbnail({ required super.key, - this.channelUrl, this.videoId, + this.customUrl, + this.urlSymLinkId, this.height, required this.width, this.borderRadius = 12.0, @@ -63,8 +63,6 @@ class YoutubeThumbnail extends StatefulWidget { this.compressed = true, required this.isImportantInCache, this.preferLowerRes = true, - this.channelIDForHQImage = '', - this.hqChannelImage = false, this.isPlaylist = false, this.iconSize, this.boxShadow, @@ -81,7 +79,7 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe Color? smallBoxDynamicColor; final _thumbnailNotFound = false.obs; - bool get canFetchYTImage => widget.videoId != null || widget.channelUrl != null; + bool get canFetchYTImage => widget.videoId != null || widget.customUrl != null; bool get canFetchImage => widget.localImagePath != null || canFetchYTImage; Timer? _dontTouchMeImFetchingThumbnail; @@ -95,7 +93,7 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe @override void dispose() { if (widget.videoId != null) ThumbnailManager.inst.closeThumbnailClients(widget.videoId!); - if (widget.channelUrl != null) ThumbnailManager.inst.closeThumbnailClients(widget.channelUrl!); + if (widget.customUrl != null) ThumbnailManager.inst.closeThumbnailClients(widget.customUrl!); _dontTouchMeImFetchingThumbnail?.cancel(); _dontTouchMeImFetchingThumbnail = null; _thumbnailNotFound.close(); @@ -113,19 +111,17 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe void onThumbnailNotFound() => _thumbnailNotFound.value = true; if (imagePath == null) { - final fetchHQChImg = widget.channelIDForHQImage != ''; - final finalChAvatarUrl = fetchHQChImg ? widget.channelIDForHQImage : widget.channelUrl; final videoId = widget.videoId; File? res = ThumbnailManager.inst.getYoutubeThumbnailFromCacheSync( id: videoId, - channelUrl: finalChAvatarUrl, + customUrl: widget.customUrl, isTemp: false, ); if (res == null && (!widget.isImportantInCache || widget.preferLowerRes)) { res = ThumbnailManager.inst.getYoutubeThumbnailFromCacheSync( id: videoId, - channelUrl: finalChAvatarUrl, + customUrl: widget.customUrl, isTemp: true, ); } @@ -140,8 +136,6 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe if (widget.isImportantInCache && !widget.preferLowerRes) { res = await ThumbnailManager.inst.getYoutubeThumbnailAndCache( id: videoId, - channelUrlOrID: null, - hqChannelImage: fetchHQChImg, isImportantInCache: true, onNotFound: onThumbnailNotFound, ); @@ -151,9 +145,8 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe } else { // for channels/playlists -> default res = await ThumbnailManager.inst.getYoutubeThumbnailAndCache( - id: null, - channelUrlOrID: finalChAvatarUrl, - hqChannelImage: fetchHQChImg, + customUrl: widget.customUrl, + symlinkId: widget.urlSymLinkId, isImportantInCache: widget.isImportantInCache, onNotFound: onThumbnailNotFound, ); @@ -181,7 +174,7 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe } } - Key get thumbKey => Key("$smallBoxDynamicColor${widget.videoId}${widget.channelUrl}${widget.channelIDForHQImage}$imagePath${widget.smallBoxText}"); + Key get thumbKey => Key("$smallBoxDynamicColor${widget.videoId}${widget.customUrl}${widget.urlSymLinkId}$imagePath${widget.smallBoxText}"); @override Widget build(BuildContext context) { @@ -208,10 +201,10 @@ class _YoutubeThumbnailState extends State with LoadingItemsDe boxShadow: widget.boxShadow, icon: widget.isPlaylist ? Broken.music_library_2 - : widget.channelUrl != null + : widget.customUrl != null ? Broken.user : Broken.video, - iconSize: widget.iconSize ?? (widget.channelUrl != null ? null : widget.width * 0.3), + iconSize: widget.iconSize ?? (widget.customUrl != null ? null : widget.width * 0.3), forceSquared: widget.forceSquared, // cacheHeight: (widget.height?.round() ?? widget.width.round()) ~/ 1.2, onTopWidgets: [ diff --git a/lib/youtube/widgets/yt_video_card.dart b/lib/youtube/widgets/yt_video_card.dart index faffbdd7..5282b4e4 100644 --- a/lib/youtube/widgets/yt_video_card.dart +++ b/lib/youtube/widgets/yt_video_card.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:playlist_manager/module/playlist_id.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result.dart'; +import 'package:youtipie/class/result_wrapper/playlist_result_base.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/youtipie.dart'; import 'package:namida/controller/player_controller.dart'; import 'package:namida/core/enums.dart'; @@ -8,19 +12,18 @@ 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/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_controller.dart'; import 'package:namida/youtube/functions/yt_playlist_utils.dart'; import 'package:namida/youtube/widgets/yt_card.dart'; import 'package:namida/youtube/yt_utils.dart'; class YoutubeVideoCard extends StatelessWidget { - final StreamInfoItem? video; + final StreamInfoItem video; final PlaylistID? playlistID; final bool isImageImportantInCache; final void Function()? onTap; final double? thumbnailWidth; final double? thumbnailHeight; - final YoutubePlaylist? playlist; + final YoutiPiePlaylistResultBase? playlist; final int? index; final double fontMultiplier; final double thumbnailWidthPercentage; @@ -42,21 +45,27 @@ class YoutubeVideoCard extends StatelessWidget { }); List getMenuItems() { - final videoId = video?.id ?? ''; + final videoId = video.id; return YTUtils.getVideoCardMenuItems( videoId: videoId, - url: video?.url, - channelUrl: video?.uploaderUrl, + url: video.buildUrl(), + channelID: video.channel.id, playlistID: playlistID, - idsNamesLookup: {videoId: video?.name}, + idsNamesLookup: {videoId: video.title}, ); } @override Widget build(BuildContext context) { - final idNull = video?.id; - final videoId = idNull ?? ''; - final videoViewCount = video?.viewCount; + final videoId = video.id; + final viewsCount = video.viewsCount; + String? viewsCountText = video.viewsText; + if (viewsCount != null) { + viewsCountText = "${viewsCount.formatDecimalShort()} ${viewsCount == 0 ? lang.VIEW : lang.VIEWS}"; + } + + String publishedFromText = video.publishedFromText; + return NamidaPopupWrapper( openOnTap: false, childrenDefault: getMenuItems, @@ -67,55 +76,185 @@ class YoutubeVideoCard extends StatelessWidget { thumbnailHeight: thumbnailHeight, isImageImportantInCache: isImageImportantInCache, borderRadius: 12.0, - videoId: idNull, + videoId: videoId, thumbnailUrl: null, - shimmerEnabled: video == null, - title: video?.name ?? YoutubeController.inst.getVideoName(videoId) ?? '', + shimmerEnabled: false, + title: video.title, subtitle: [ - if (videoViewCount != null && videoViewCount >= 0) "${videoViewCount.formatDecimalShort()} ${videoViewCount == 0 ? lang.VIEW : lang.VIEWS}", - if (video?.textualUploadDate != null) video?.textualUploadDate, + if (viewsCountText != null && viewsCountText.isNotEmpty) viewsCountText, + if (publishedFromText.isNotEmpty) publishedFromText, ].join(' - '), displaythirdLineText: true, - thirdLineText: dateInsteadOfChannel - ? video?.date?.millisecondsSinceEpoch.dateAndClockFormattedOriginal ?? '' - : video?.uploaderName ?? YoutubeController.inst.getVideoChannelName(videoId) ?? '', + thirdLineText: dateInsteadOfChannel ? video.publishedAt.date?.millisecondsSinceEpoch.dateAndClockFormattedOriginal ?? '' : video.channel.title, displayChannelThumbnail: !dateInsteadOfChannel, - channelThumbnailUrl: video?.uploaderAvatarUrl, + channelThumbnailUrl: video.channel.thumbnails.pick()?.url, onTap: onTap ?? () async { - if (idNull != null) { - Player.inst.playOrPause( - 0, - [YoutubeID(id: videoId, playlistID: playlistID)], - QueueSource.others, - onAssigningCurrentItem: (currentItem) async { - // -- add the remaining playlist videos, only if the same item is still playing - final playlist = this.playlist; - final index = this.index; - - if (playlist != null && index != null) { - await playlist.fetchAllPlaylistStreams(context: null); - if (currentItem is YoutubeID && currentItem.id == videoId) { - try { - final firstHalf = playlist.streams.getRange(0, index).map((e) => YoutubeID(id: e.id ?? '', playlistID: playlistID)); - final lastHalf = playlist.streams.getRange(index + 1, playlist.streams.length).map((e) => YoutubeID(id: e.id ?? '', playlistID: playlistID)); - - Player.inst.addToQueue(lastHalf); // adding first bcz inserting would mess up indexes in lastHalf. - await Player.inst.insertInQueue(firstHalf, 0); - } catch (e) { - printy(e, isError: true); - } - } - } - }, - ); - YTUtils.expandMiniplayer(); - } + _VideoCardUtils.onVideoTap( + videoId: videoId, + index: index, + playlist: playlist, + playlistID: playlistID, + ); }, - smallBoxText: video?.duration?.inSeconds.secondsLabel, - bottomRightWidgets: idNull == null ? [] : YTUtils.getVideoCacheStatusIcons(videoId: idNull, context: context), + smallBoxText: video.durSeconds?.secondsLabel, + bottomRightWidgets: YTUtils.getVideoCacheStatusIcons(videoId: videoId, context: context), menuChildrenDefault: getMenuItems, ), ); } } + +class YoutubeShortVideoCard extends StatelessWidget { + final StreamInfoItemShort short; + final PlaylistID? playlistID; + final void Function()? onTap; + final double? thumbnailWidth; + final double? thumbnailHeight; + final YoutiPiePlaylistResult? playlist; + final int? index; + final double fontMultiplier; + final double thumbnailWidthPercentage; + final bool dateInsteadOfChannel; + + const YoutubeShortVideoCard({ + super.key, + required this.short, + this.playlistID, + this.onTap, + this.thumbnailWidth, + this.thumbnailHeight, + this.playlist, + this.index, + this.fontMultiplier = 1.0, + this.thumbnailWidthPercentage = 1.0, + this.dateInsteadOfChannel = false, + }); + + List getMenuItems() { + final videoId = short.id; + return YTUtils.getVideoCardMenuItems( + videoId: videoId, + url: short.buildUrl(), + channelID: null, + playlistID: playlistID, + idsNamesLookup: {videoId: short.title}, + ); + } + + @override + Widget build(BuildContext context) { + final String videoId = short.id; + final String viewsCountText = short.viewsText; + + return NamidaPopupWrapper( + openOnTap: false, + childrenDefault: getMenuItems, + child: YoutubeCard( + thumbnailWidthPercentage: thumbnailWidthPercentage, + fontMultiplier: fontMultiplier, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + isImageImportantInCache: false, + borderRadius: 12.0, + videoId: short.id, + thumbnailUrl: null, + shimmerEnabled: false, + title: short.title, + subtitle: viewsCountText, + displaythirdLineText: false, + thirdLineText: '', + displayChannelThumbnail: !dateInsteadOfChannel, + channelThumbnailUrl: null, + onTap: onTap ?? + () { + _VideoCardUtils.onVideoTap( + videoId: videoId, + index: index, + playlist: playlist, + playlistID: playlistID, + ); + }, + bottomRightWidgets: YTUtils.getVideoCacheStatusIcons(videoId: short.id, context: context), + menuChildrenDefault: getMenuItems, + ), + ); + } +} + +class YoutubeVideoCardDummy extends StatelessWidget { + final double? thumbnailWidth; + final double? thumbnailHeight; + final double fontMultiplier; + final double thumbnailWidthPercentage; + final bool shimmerEnabled; + final bool displaythirdLineText; + final bool dateInsteadOfChannel; + + const YoutubeVideoCardDummy({ + super.key, + this.thumbnailWidth, + this.thumbnailHeight, + this.fontMultiplier = 1.0, + this.thumbnailWidthPercentage = 1.0, + required this.shimmerEnabled, + this.displaythirdLineText = true, + this.dateInsteadOfChannel = false, + }); + + @override + Widget build(BuildContext context) { + return YoutubeCard( + thumbnailWidthPercentage: thumbnailWidthPercentage, + fontMultiplier: fontMultiplier, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight, + isImageImportantInCache: false, + borderRadius: 12.0, + videoId: null, + thumbnailUrl: null, + shimmerEnabled: shimmerEnabled, + title: '', + subtitle: '', + displaythirdLineText: displaythirdLineText, + thirdLineText: '', + displayChannelThumbnail: !dateInsteadOfChannel, + channelThumbnailUrl: null, + ); + } +} + +class _VideoCardUtils { + static Future onVideoTap({ + required String videoId, + PlaylistID? playlistID, + int? index, + YoutiPiePlaylistResultBase? playlist, + }) async { + YTUtils.expandMiniplayer(); + return Player.inst.playOrPause( + 0, + [YoutubeID(id: videoId, playlistID: playlistID)], + QueueSource.others, + onAssigningCurrentItem: (currentItem) async { + // -- add the remaining playlist videos, only if the same item is still playing + + if (playlist != null && index != null) { + await playlist.basicInfo.fetchAllPlaylistStreams(showProgressSheet: false, playlist: playlist); + if (currentItem != Player.inst.currentItem.value) return; // nvm if item changed + if (currentItem is YoutubeID && currentItem.id == videoId) { + try { + final firstHalf = playlist.items.getRange(0, index).map((e) => YoutubeID(id: e.id, playlistID: playlistID)); + final lastHalf = playlist.items.getRange(index + 1, playlist.items.length).map((e) => YoutubeID(id: e.id, playlistID: playlistID)); + + Player.inst.addToQueue(lastHalf); // adding first bcz inserting would mess up indexes in lastHalf. + await Player.inst.insertInQueue(firstHalf, 0); + } catch (e) { + printo(e, isError: true); + } + } + } + }, + ); + } +} diff --git a/lib/youtube/widgets/yt_videos_actions_bar.dart b/lib/youtube/widgets/yt_videos_actions_bar.dart index 99a1edc9..d7299580 100644 --- a/lib/youtube/widgets/yt_videos_actions_bar.dart +++ b/lib/youtube/widgets/yt_videos_actions_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:newpipeextractor_dart/models/stream_info_item.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/player_controller.dart'; @@ -37,16 +37,16 @@ class YTVideosActionBarOptions { class YTVideosActionBar extends StatelessWidget { final String title; - final String url; - final List Function() videosCallback; - final Map Function()? infoLookupCallback; + final String Function()? urlBuilder; + final List? Function() videosCallback; + final Map? Function()? infoLookupCallback; final YTVideosActionBarOptions barOptions; final YTVideosActionBarOptions menuOptions; const YTVideosActionBar({ super.key, required this.title, - required this.url, + required this.urlBuilder, required this.videosCallback, this.infoLookupCallback, this.barOptions = const YTVideosActionBarOptions(), @@ -61,46 +61,60 @@ class YTVideosActionBar extends StatelessWidget { ), }); - List get videos => videosCallback(); - Map get infoLookup => infoLookupCallback?.call() ?? {}; - Future _onDownloadTap() async { + final videos = videosCallback(); + if (videos == null) return; await NamidaNavigator.inst.navigateTo( YTPlaylistDownloadPage( ids: videos, playlistName: title, - infoLookup: infoLookup, + infoLookup: infoLookupCallback?.call() ?? {}, ), ); } Future _onShuffle() async { + final videos = videosCallback(); + if (videos == null) return; await Player.inst.playOrPause(0, videos, QueueSource.others, shuffle: true); } Future _onPlay() async { + final videos = videosCallback(); + if (videos == null) return; await Player.inst.playOrPause(0, videos, QueueSource.others); } Future _onPlayNext() async { + final videos = videosCallback(); + if (videos == null) return; await Player.inst.addToQueue(videos, insertNext: true); } Future _onPlayAfter() async { + final videos = videosCallback(); + if (videos == null) return; await Player.inst.addToQueue(videos, insertAfterLatest: true); } Future _onPlayLast() async { + final videos = videosCallback(); + if (videos == null) return; await Player.inst.addToQueue(videos, insertNext: false); } void _onAddToPlaylist() { + final videos = videosCallback(); + if (videos == null) return; + final ids = []; final info = {}; + + final infoLookup = infoLookupCallback?.call() ?? {}; videos.loop((e) { final id = e.id; ids.add(id); - info[id] = infoLookup[id]?.name; + info[id] = infoLookup[id]?.title; }); showAddToPlaylistSheet( @@ -110,8 +124,10 @@ class YTVideosActionBar extends StatelessWidget { } List getMenuItems() { - final countText = videos.length; + final videos = videosCallback(); + final videosCount = videos?.length ?? 0; final playAfterVid = menuOptions.playAfter ? YTUtils.getPlayerAfterVideo() : null; + final url = urlBuilder?.call(); return [ if (menuOptions.addToPlaylist) NamidaPopupItem( @@ -119,7 +135,7 @@ class YTVideosActionBar extends StatelessWidget { title: lang.ADD_TO_PLAYLIST, onTap: _onAddToPlaylist, ), - if (url != '') + if (url != null && url != '') NamidaPopupItem( icon: Broken.share, title: lang.SHARE, @@ -131,38 +147,40 @@ class YTVideosActionBar extends StatelessWidget { 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 (playAfterVid != null) - NamidaPopupItem( - icon: Broken.hierarchy_square, - title: "${lang.PLAY_AFTER}: ${playAfterVid.diff.displayVideoKeyword}", - subtitle: playAfterVid.name, - oneLinedSub: true, - onTap: _onPlayAfter, - ), - if (menuOptions.playLast) - NamidaPopupItem( - icon: Broken.play_cricle, - title: "${lang.PLAY_LAST} ($countText)", - onTap: _onPlayLast, - ), + if (videosCount > 0) ...[ + if (menuOptions.shuffle) + NamidaPopupItem( + icon: Broken.shuffle, + title: "${lang.SHUFFLE} ($videosCount)", + onTap: _onShuffle, + ), + if (menuOptions.play) + NamidaPopupItem( + icon: Broken.play, + title: "${lang.PLAY} ($videosCount)", + onTap: _onPlay, + ), + if (menuOptions.playNext) + NamidaPopupItem( + icon: Broken.next, + title: "${lang.PLAY_NEXT} ($videosCount)", + onTap: _onPlayNext, + ), + if (playAfterVid != null) + NamidaPopupItem( + icon: Broken.hierarchy_square, + title: "${lang.PLAY_AFTER}: ${playAfterVid.diff.displayVideoKeyword}", + subtitle: playAfterVid.name, + oneLinedSub: true, + onTap: _onPlayAfter, + ), + if (menuOptions.playLast) + NamidaPopupItem( + icon: Broken.play_cricle, + title: "${lang.PLAY_LAST} ($videosCount)", + onTap: _onPlayLast, + ), + ], ]; } diff --git a/lib/youtube/youtube_miniplayer.dart b/lib/youtube/youtube_miniplayer.dart index be7f4a3b..2232a92a 100644 --- a/lib/youtube/youtube_miniplayer.dart +++ b/lib/youtube/youtube_miniplayer.dart @@ -1,11 +1,14 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:youtipie/class/youtipie_feed/playlist_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; +import 'package:youtipie/class/stream_info_item/stream_info_item_short.dart'; -import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/miniplayer_controller.dart'; import 'package:namida/controller/navigator_controller.dart'; @@ -25,16 +28,18 @@ 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/class/youtube_id.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_info_controller.dart'; +import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_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/seek_ready_widget.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_queue_chip.dart'; @@ -44,6 +49,7 @@ 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'; import 'package:namida/youtube/yt_utils.dart'; +import 'package:youtipie/youtipie.dart'; const _space2ForThumbnail = 90.0; const _extraPaddingForYTMiniplayer = 12.0; @@ -53,10 +59,10 @@ class YoutubeMiniPlayer extends StatefulWidget { const YoutubeMiniPlayer({super.key}); @override - State createState() => _YoutubeMiniPlayerState(); + State createState() => YoutubeMiniPlayerState(); } -class _YoutubeMiniPlayerState extends State { +class YoutubeMiniPlayerState extends State { final _numberOfRepeats = 1.obs; bool _canScrollQueue = true; @@ -69,9 +75,59 @@ class _YoutubeMiniPlayerState extends State { setState(() => _canScrollQueue = can); } + final _scrollController = ScrollController(); + + void resetGlowUnderVideo() => _shouldShowGlowUnderVideo.value = false; + + final _shouldShowGlowUnderVideo = false.obs; + final _isTitleExpanded = false.obs; + final _canDimMiniplayer = false.obs; + Timer? _dimTimer; + + void cancelDimTimer() { + _dimTimer?.cancel(); + _dimTimer = null; + _canDimMiniplayer.value = false; + } + + void startDimTimer() { + cancelDimTimer(); + final int defaultMiniplayerDimSeconds = settings.ytMiniplayerDimAfterSeconds.value; + if (defaultMiniplayerDimSeconds <= 0) return; + final double defaultMiniplayerOpacity = settings.ytMiniplayerDimOpacity.value; + if (defaultMiniplayerOpacity <= 0) return; + _dimTimer = Timer(Duration(seconds: defaultMiniplayerDimSeconds), () { + _canDimMiniplayer.value = true; + }); + } + + void _onVideoPageReset() { + try { + _scrollController.jumpTo(0); + } catch (_) {} + resetGlowUnderVideo(); + startDimTimer(); + _isTitleExpanded.value = false; + } + + @override + void initState() { + super.initState(); + _scrollController.addListener(() { + final pixels = _scrollController.positions.lastOrNull?.pixels; + final hasScrolledEnough = pixels != null && pixels > 40; + _shouldShowGlowUnderVideo.value = hasScrolledEnough; + }); + YoutubeInfoController.current.onVideoPageReset = _onVideoPageReset; + } + @override void dispose() { + YoutubeInfoController.current.onVideoPageReset = null; + _scrollController.dispose(); _numberOfRepeats.close(); + _isTitleExpanded.close(); + _shouldShowGlowUnderVideo.close(); super.dispose(); } @@ -88,14 +144,18 @@ class _YoutubeMiniPlayerState extends State { const relatedThumbnailWidth = Dimensions.youtubeThumbnailWidth; const relatedThumbnailItemExtent = relatedThumbnailHeight + 8.0 * 2; - final miniplayerBGColor = Color.alphaBlend(context.theme.secondaryHeaderColor.withOpacity(0.25), context.theme.scaffoldBackgroundColor); - const seekReadyWidget = SeekReadyWidget(); + final maxWidth = context.width; + final mainTheme = context.theme; + final mainTextTheme = context.textTheme; + + final miniplayerBGColor = Color.alphaBlend(mainTheme.secondaryHeaderColor.withOpacity(0.25), mainTheme.scaffoldBackgroundColor); + final absorbBottomDragWidget = AbsorbPointer( child: SizedBox( height: 18.0, - width: context.width, + width: maxWidth, ), ); @@ -103,7 +163,7 @@ class _YoutubeMiniPlayerState extends State { key: const Key('dimmie'), child: IgnorePointer( child: ObxO( - rx: YoutubeController.inst.canDimMiniplayer, + rx: _canDimMiniplayer, builder: (canDimMiniplayer) => AnimatedSwitcher( duration: const Duration(milliseconds: 600), reverseDuration: const Duration(milliseconds: 200), @@ -125,23 +185,26 @@ class _YoutubeMiniPlayerState extends State { alignment: Alignment.centerRight, child: SizedBox( height: context.height, - width: (context.width * 0.25).withMaximum(324.0), + width: (maxWidth * 0.25).withMaximum(324.0), child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { + if (NamidaNavigator.inst.isInYTCommentsSubpage) return; _mpState?.updatePercentageMultiplier(true); _mpState?.setDragExternally(true); _mpState?.saveDragHeightStart(); _velocity.addPosition(event.timeStamp, event.position); }, onPointerMove: (event) { + if (NamidaNavigator.inst.isInYTCommentsSubpage) return; if (!_canScrollQueue) { _mpState?.onVerticalDragUpdate(event.delta.dy); _velocity.addPosition(event.timeStamp, event.position); } }, onPointerUp: (event) { - if (YoutubeController.inst.scrollController.hasClients && YoutubeController.inst.scrollController.position.pixels <= 0) { + if (NamidaNavigator.inst.isInYTCommentsSubpage) return; + if (_scrollController.hasClients && _scrollController.position.pixels <= 0) { _mpState?.onVerticalDragEnd(_velocity.getVelocity().pixelsPerSecond.dy); } _mpState?.updatePercentageMultiplier(false); @@ -155,1044 +218,1102 @@ class _YoutubeMiniPlayerState extends State { ); return DefaultTextStyle( - style: context.textTheme.displayMedium!, + style: mainTextTheme.displayMedium!, child: ObxO( rx: YoutubePlaylistController.inst.favouritesPlaylist, builder: (favouritesPlaylist) { - return Obx( - () { - final videoInfo = YoutubeController.inst.currentYoutubeMetadataVideo.valueR ?? Player.inst.currentVideoInfo.valueR; - final videoChannel = YoutubeController.inst.currentYoutubeMetadataChannel.valueR; + return ObxO( + rx: Player.inst.currentItem, + builder: (currentItem) { + if (currentItem is! YoutubeID) return const SizedBox(); - String? uploadDate; - String? uploadDateAgo; + final currentId = currentItem.id; + final isUserLiked = favouritesPlaylist.tracks.firstWhereEff((element) => element.id == currentId) != null; - final parsedDate = videoInfo?.date ?? Player.inst.currentVideoInfo.valueR?.date; + return ObxO( + rx: YoutubeInfoController.current.currentYTStreams, + builder: (streams) => ObxO( + rx: YoutubeInfoController.current.currentVideoPage, + builder: (page) { + final videoInfo = page?.videoInfo; + final videoInfoStream = streams?.info; + final channel = page?.channelInfo; - if (parsedDate != null) { - uploadDate = parsedDate.millisecondsSinceEpoch.dateFormattedOriginal; - uploadDateAgo = Jiffy.parseFromDateTime(parsedDate).fromNow(); - } + String? uploadDate; + String? uploadDateAgo; - final miniTitle = videoInfo?.name; - final miniSubtitle = videoChannel?.name ?? videoInfo?.uploaderName; - final currentId = Player.inst.getCurrentVideoIdR; + final parsedDate = videoInfo?.publishedAt.date ?? videoInfoStream?.publishedAt.date ?? videoInfoStream?.publishDate.date; - final channelName = videoChannel?.name ?? videoInfo?.uploaderName; - final channelThumbnail = videoChannel?.avatarUrl ?? videoInfo?.uploaderAvatarUrl; - final channelIsVerified = videoChannel?.isVerified ?? videoInfo?.isUploaderVerified ?? false; - final channelSubs = videoChannel?.subscriberCount ?? Player.inst.currentChannelInfo.valueR?.subscriberCount; - final channelIDOrURL = videoChannel?.id ?? videoInfo?.uploaderUrl ?? Player.inst.currentChannelInfo.valueR?.id; + if (parsedDate != null) { + uploadDate = parsedDate.millisecondsSinceEpoch.dateFormattedOriginal; + uploadDateAgo = Jiffy.parseFromDateTime(parsedDate).fromNow(); + } else { + uploadDateAgo = videoInfo?.publishedFromText; + } + final videoTitle = videoInfo?.title ?? videoInfoStream?.title; + final channelName = channel?.title ?? videoInfoStream?.channelName; - final isUserLiked = favouritesPlaylist.tracks.firstWhereEff((element) => element.id == currentId) != null; + final channelThumbnail = channel?.thumbnails.pick()?.url; + final channelIsVerified = channel?.isVerified ?? false; + final channelSubs = channel?.subscribersCount; + final channelID = channel?.id ?? videoInfoStream?.channelId; - final videoLikeCount = (isUserLiked ? 1 : 0) + (videoInfo?.likeCount ?? Player.inst.currentVideoInfo.valueR?.likeCount ?? 0); - final videoDislikeCount = videoInfo?.dislikeCount ?? Player.inst.currentVideoInfo.valueR?.dislikeCount; - final videoViewCount = videoInfo?.viewCount ?? Player.inst.currentVideoInfo.valueR?.viewCount; + final videoLikeCount = (isUserLiked ? 1 : 0) + (videoInfo?.engagement?.likesCount ?? 0); + const int? videoDislikeCount = null; + final videoViewCount = videoInfo?.viewsCount; - final description = videoInfo?.description; - final descriptionWidget = description == null || description == '' - ? null - : SelectionArea( - child: Html( - data: description, - style: { - '*': Style.fromTextStyle( - context.textTheme.displayMedium!.copyWith( - fontSize: 14.0, - ), - ), - 'a': Style.fromTextStyle( - context.textTheme.displayMedium!.copyWith( - color: context.theme.colorScheme.primary.withAlpha(210), - fontSize: 13.5, + final description = videoInfo?.description; + final descriptionWidget = description == null || description == '' + ? null + : SelectionArea( + child: Html( + data: description, + style: { + '*': Style.fromTextStyle( + mainTextTheme.displayMedium!.copyWith( + fontSize: 14.0, + ), + ), + 'a': Style.fromTextStyle( + mainTextTheme.displayMedium!.copyWith( + color: mainTheme.colorScheme.primary.withAlpha(210), + fontSize: 13.5, + ), + ) + }, + onLinkTap: (url, attributes, element) async { + if (url != null) { + final partsDur = url.split("$currentId&t="); + if (partsDur.length > 1) { + try { + await Player.inst.seek(Duration(seconds: int.parse(partsDur.last))); + } catch (e) { + snackyy(title: lang.ERROR, message: e.toString(), isError: true, top: false); + } + } else { + await NamidaLinkUtils.openLink(url); + } + } + }, ), - ) - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final partsDur = url.split("$currentId&t="); - if (partsDur.length > 1) { - try { - await Player.inst.seek(Duration(seconds: int.parse(partsDur.last))); - } catch (e) { - snackyy(title: lang.ERROR, message: e.toString(), isError: true, top: false); - } - } else { - await NamidaLinkUtils.openLink(url); - } - } - }, - ), - ); + ); - YoutubeController.inst.downloadedFilesMap; // for refreshing. - final downloadedFileExists = YoutubeController.inst.doesIDHasFileDownloaded(currentId) != null; + YoutubeController.inst.downloadedFilesMap; // for refreshing. + final downloadedFileExists = YoutubeController.inst.doesIDHasFileDownloaded(currentId) != null; - final defaultIconColor = context.defaultIconColor(CurrentColor.inst.miniplayerColor); + final defaultIconColor = context.defaultIconColor(CurrentColor.inst.miniplayerColor); - // ==== MiniPlayer Body, contains title, description, comments, ..etc. ==== - final miniplayerBody = Stack( - alignment: Alignment.bottomCenter, // bottom alignment is for touch absorber - children: [ - // opacity: (percentage * 4 - 3).withMinimum(0), - Listener( - key: Key("${currentId}_body_listener"), - onPointerMove: (event) { - if (event.delta.dy > 0) { - if (YoutubeController.inst.scrollController.hasClients) { - if (YoutubeController.inst.scrollController.position.pixels <= 0) { - _updateCanScrollQueue(false); - } - } - } else { - if (_mpState == null || _mpState?.controller.value == 1) _updateCanScrollQueue(true); - } - }, - onPointerDown: (_) { - YoutubeController.inst.cancelDimTimer(); - _updateCanScrollQueue(true); - }, - onPointerUp: (_) { - YoutubeController.inst.startDimTimer(); - _updateCanScrollQueue(true); - }, - child: Navigator( - key: NamidaNavigator.inst.ytMiniplayerCommentsPageKey, - requestFocus: false, - onPopPage: (route, result) => false, - restorationScopeId: currentId, - pages: [ - MaterialPage( - maintainState: true, - child: IgnorePointer( - ignoring: !_canScrollQueue, - child: LazyLoadListView( - key: Key("${currentId}_body_lazy_load_list"), - onReachingEnd: () async { - if (settings.ytTopComments.value) return; - await YoutubeController.inst.updateCurrentComments(currentId, fetchNextOnly: true); - }, - extend: 400, - scrollController: YoutubeController.inst.scrollController, - listview: (controller) => Stack( - key: Key("${currentId}_body_stack"), - children: [ - CustomScrollView( - // key: PageStorageKey(currentId), // duplicate errors - physics: _canScrollQueue ? const ClampingScrollPhysicsModified() : const NeverScrollableScrollPhysics(), - controller: controller, - slivers: [ - // --START-- title & subtitle - SliverToBoxAdapter( - key: Key("${currentId}_title"), - child: ShimmerWrapper( - shimmerDurationMS: 550, - shimmerDelayMS: 250, - shimmerEnabled: videoInfo == null, - child: ExpansionTile( - // key: Key(currentId), - initiallyExpanded: false, - maintainState: false, - expandedAlignment: Alignment.centerLeft, - expandedCrossAxisAlignment: CrossAxisAlignment.start, - tilePadding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 14.0), - textColor: Color.alphaBlend(CurrentColor.inst.miniplayerColor.withAlpha(40), context.theme.colorScheme.onSurface), - collapsedTextColor: context.theme.colorScheme.onSurface, - iconColor: Color.alphaBlend(CurrentColor.inst.miniplayerColor.withAlpha(40), context.theme.colorScheme.onSurface), - collapsedIconColor: context.theme.colorScheme.onSurface, - childrenPadding: const EdgeInsets.all(18.0), - onExpansionChanged: (value) => YoutubeController.inst.isTitleExpanded.value = value, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Obx( - () { - final videoListens = YoutubeHistoryController.inst.topTracksMapListens[currentId] ?? []; - if (videoListens.isEmpty) return const SizedBox(); - return NamidaInkWell( - borderRadius: 6.0, - bgColor: CurrentColor.inst.miniplayerColor.withOpacity(0.7), - onTap: () { - showVideoListensDialog(currentId); - }, - padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), - child: Text( - videoListens.length.formatDecimal(), - style: context.textTheme.displaySmall?.copyWith( - color: Colors.white.withOpacity(0.6), - ), + // ==== MiniPlayer Body, contains title, description, comments, ..etc. ==== + final miniplayerBody = Stack( + alignment: Alignment.bottomCenter, // bottom alignment is for touch absorber + children: [ + // opacity: (percentage * 4 - 3).withMinimum(0), + Listener( + key: Key("${currentId}_body_listener"), + onPointerMove: (event) { + if (event.delta.dy > 0) { + if (_scrollController.hasClients) { + if (_scrollController.position.pixels <= 0) { + _updateCanScrollQueue(false); + } + } + } else { + if (_mpState == null || _mpState?.controller.value == 1) _updateCanScrollQueue(true); + } + }, + onPointerDown: (_) { + cancelDimTimer(); + _updateCanScrollQueue(true); + }, + onPointerUp: (_) { + startDimTimer(); + _updateCanScrollQueue(true); + }, + child: Navigator( + key: NamidaNavigator.inst.ytMiniplayerCommentsPageKey, + requestFocus: false, + onPopPage: (route, result) => false, + restorationScopeId: currentId, + pages: [ + MaterialPage( + maintainState: true, + child: IgnorePointer( + ignoring: !_canScrollQueue, + child: LazyLoadListView( + key: Key("${currentId}_body_lazy_load_list"), + onReachingEnd: () async { + if (settings.ytTopComments.value) return; + await YoutubeInfoController.current.updateCurrentComments(currentId); + }, + extend: 400, + scrollController: _scrollController, + listview: (controller) => Stack( + key: Key("${currentId}_body_stack"), + children: [ + CustomScrollView( + // key: PageStorageKey(currentId), // duplicate errors + physics: _canScrollQueue ? const ClampingScrollPhysicsModified() : const NeverScrollableScrollPhysics(), + controller: controller, + slivers: [ + // --START-- title & subtitle + SliverToBoxAdapter( + key: Key("${currentId}_title"), + child: ShimmerWrapper( + shimmerDurationMS: 550, + shimmerDelayMS: 250, + shimmerEnabled: page == null, + child: ExpansionTile( + // key: Key(currentId), + initiallyExpanded: false, + maintainState: false, + expandedAlignment: Alignment.centerLeft, + expandedCrossAxisAlignment: CrossAxisAlignment.start, + tilePadding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 14.0), + textColor: Color.alphaBlend(CurrentColor.inst.miniplayerColor.withAlpha(40), mainTheme.colorScheme.onSurface), + collapsedTextColor: mainTheme.colorScheme.onSurface, + iconColor: Color.alphaBlend(CurrentColor.inst.miniplayerColor.withAlpha(40), mainTheme.colorScheme.onSurface), + collapsedIconColor: mainTheme.colorScheme.onSurface, + childrenPadding: const EdgeInsets.all(18.0), + onExpansionChanged: (value) => _isTitleExpanded.value = value, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Obx( + () { + final videoListens = YoutubeHistoryController.inst.topTracksMapListens[currentId] ?? []; + if (videoListens.isEmpty) return const SizedBox(); + return NamidaInkWell( + borderRadius: 6.0, + bgColor: CurrentColor.inst.miniplayerColor.withOpacity(0.7), + onTap: () { + showVideoListensDialog(currentId); + }, + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), + child: Text( + videoListens.length.formatDecimal(), + style: mainTextTheme.displaySmall?.copyWith( + color: Colors.white.withOpacity(0.6), + ), + ), + ); + }, ), - ); - }, - ), - const SizedBox(width: 8.0), - NamidaPopupWrapper( - onPop: () { - _numberOfRepeats.value = 1; - }, - childrenDefault: () { - final videoId = currentId; - final items = YTUtils.getVideoCardMenuItems( - videoId: videoId, - url: videoInfo?.url, - channelUrl: channelIDOrURL, - playlistID: null, - idsNamesLookup: {videoId: videoInfo?.name}, - ); - if (Player.inst.currentVideo != null && videoId == Player.inst.getCurrentVideoId) { - final repeatForWidget = NamidaPopupItem( - icon: Broken.cd, - title: '', - titleBuilder: (style) => Obx( - () => Text( - lang.REPEAT_FOR_N_TIMES.replaceFirst('_NUM_', _numberOfRepeats.valueR.toString()), - style: style, - ), - ), - onTap: () { - settings.player.save(repeatMode: RepeatMode.forNtimes); - Player.inst.updateNumberOfRepeats(_numberOfRepeats.value); + const SizedBox(width: 8.0), + NamidaPopupWrapper( + onPop: () { + _numberOfRepeats.value = 1; }, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - NamidaIconButton( - icon: Broken.minus_cirlce, - onPressed: () => _numberOfRepeats.value = (_numberOfRepeats.value - 1).clamp(1, 20), - iconSize: 20.0, - ), - NamidaIconButton( - icon: Broken.add_circle, - onPressed: () => _numberOfRepeats.value = (_numberOfRepeats.value + 1).clamp(1, 20), - iconSize: 20.0, + childrenDefault: () { + final videoId = currentId; + final items = YTUtils.getVideoCardMenuItems( + videoId: videoId, + url: videoInfo?.buildUrl(), + channelID: channelID, + playlistID: null, + idsNamesLookup: {videoId: videoTitle}, + ); + if (Player.inst.currentVideo != null && videoId == Player.inst.currentVideo?.id) { + final repeatForWidget = NamidaPopupItem( + icon: Broken.cd, + title: '', + titleBuilder: (style) => Obx( + () => Text( + lang.REPEAT_FOR_N_TIMES.replaceFirst('_NUM_', _numberOfRepeats.valueR.toString()), + style: style, + ), + ), + onTap: () { + settings.player.save(repeatMode: RepeatMode.forNtimes); + Player.inst.updateNumberOfRepeats(_numberOfRepeats.value); + }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + NamidaIconButton( + icon: Broken.minus_cirlce, + onPressed: () => _numberOfRepeats.value = (_numberOfRepeats.value - 1).clamp(1, 20), + iconSize: 20.0, + ), + NamidaIconButton( + icon: Broken.add_circle, + onPressed: () => _numberOfRepeats.value = (_numberOfRepeats.value + 1).clamp(1, 20), + iconSize: 20.0, + ), + ], + ), + ); + items.add(repeatForWidget); + } + items.add( + NamidaPopupItem( + icon: Broken.trash, + title: lang.CLEAR, + onTap: () { + YTUtils().showVideoClearDialog(context, videoId, CurrentColor.inst.miniplayerColor); + }, ), - ], - ), - ); - items.add(repeatForWidget); - } - items.add( - NamidaPopupItem( - icon: Broken.trash, - title: lang.CLEAR, - onTap: () { - YTUtils().showVideoClearDialog(context, videoId, CurrentColor.inst.miniplayerColor); + ); + return items; }, + child: const Icon( + Broken.arrow_down_2, + size: 20.0, + ), ), - ); - return items; - }, - child: const Icon( - Broken.arrow_down_2, - size: 20.0, - ), - ), - ], - ), - title: ObxO( - rx: YoutubeController.inst.isTitleExpanded, - builder: (isTitleExpanded) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - NamidaDummyContainer( - width: context.width * 0.8, - height: 24.0, - borderRadius: 6.0, - shimmerEnabled: videoInfo == null, - child: Text( - videoInfo?.name ?? '', - maxLines: isTitleExpanded ? 6 : 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.displayLarge, - ), + ], ), - const SizedBox(height: 4.0), - NamidaDummyContainer( - width: context.width * 0.7, - height: 12.0, - shimmerEnabled: videoInfo == null, - child: () { - final expandedDate = isTitleExpanded ? uploadDate : null; - final collapsedDate = isTitleExpanded ? null : uploadDateAgo; - return Text( - [ - if (videoViewCount != null) - "${videoViewCount.formatDecimalShort(isTitleExpanded)} ${videoViewCount == 0 ? lang.VIEW : lang.VIEWS}", - if (expandedDate != null) expandedDate, - if (collapsedDate != null) collapsedDate, - ].join(' • '), - style: context.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w500), + title: ObxO( + rx: _isTitleExpanded, + builder: (isTitleExpanded) { + String? dateToShow; + if (isTitleExpanded) { + dateToShow = uploadDate ?? uploadDateAgo; + } else { + dateToShow = uploadDateAgo ?? uploadDate; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NamidaDummyContainer( + width: maxWidth * 0.8, + height: 24.0, + borderRadius: 6.0, + shimmerEnabled: page == null, + child: Text( + videoTitle ?? '', + maxLines: isTitleExpanded ? 6 : 2, + overflow: TextOverflow.ellipsis, + style: mainTextTheme.displayLarge, + ), + ), + const SizedBox(height: 4.0), + NamidaDummyContainer( + width: maxWidth * 0.7, + height: 12.0, + shimmerEnabled: page == null, + child: Text( + [ + if (videoViewCount != null) + "${videoViewCount.formatDecimalShort(isTitleExpanded)} ${videoViewCount == 0 ? lang.VIEW : lang.VIEWS}", + if (dateToShow != null) dateToShow, + ].join(' • '), + style: mainTextTheme.displaySmall?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], ); - }(), + }, ), - ], + children: [ + if (descriptionWidget != null) descriptionWidget, + ], + ), ), ), - children: [ - if (descriptionWidget != null) descriptionWidget, - ], - ), - ), - ), - // --END-- title & subtitle + // --END-- title & subtitle - // --START-- buttons - SliverToBoxAdapter( - key: Key("${currentId}_buttons"), - child: ShimmerWrapper( - shimmerDurationMS: 550, - shimmerDelayMS: 250, - shimmerEnabled: videoInfo == null, - child: SizedBox( - width: context.width, - child: Wrap( - alignment: WrapAlignment.spaceEvenly, - children: [ - const SizedBox(width: 4.0), - ObxO( - rx: YoutubeController.inst.isTitleExpanded, - builder: (isTitleExpanded) => SmallYTActionButton( - title: videoInfo == null - ? null - : videoLikeCount < 1 - ? lang.LIKE - : videoLikeCount.formatDecimalShort(isTitleExpanded), - icon: Broken.like_1, - smallIconWidget: FittedBox( - child: NamidaRawLikeButton( - likedIcon: Broken.like_filled, - normalIcon: Broken.like_1, - disabledColor: context.theme.iconTheme.color, - isLiked: isUserLiked, - onTap: (isLiked) async { - YoutubePlaylistController.inst.favouriteButtonOnPressed(currentId); + // --START-- buttons + SliverToBoxAdapter( + key: Key("${currentId}_buttons"), + child: ShimmerWrapper( + shimmerDurationMS: 550, + shimmerDelayMS: 250, + shimmerEnabled: page == null, + child: SizedBox( + width: maxWidth, + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + const SizedBox(width: 4.0), + ObxO( + rx: _isTitleExpanded, + builder: (isTitleExpanded) => SmallYTActionButton( + title: page == null + ? null + : videoLikeCount < 1 + ? lang.LIKE + : videoLikeCount.formatDecimalShort(isTitleExpanded), + icon: Broken.like_1, + smallIconWidget: FittedBox( + child: NamidaRawLikeButton( + likedIcon: Broken.like_filled, + normalIcon: Broken.like_1, + disabledColor: mainTheme.iconTheme.color, + isLiked: isUserLiked, + onTap: (isLiked) async { + YoutubePlaylistController.inst.favouriteButtonOnPressed(currentId); + }, + ), + ), + ), + ), + const SizedBox(width: 4.0), + ObxO( + rx: _isTitleExpanded, + builder: (isTitleExpanded) => SmallYTActionButton( + title: (videoDislikeCount ?? 0) < 1 ? lang.DISLIKE : videoDislikeCount?.formatDecimalShort(isTitleExpanded) ?? '?', + icon: Broken.dislike, + onPressed: () {}, + ), + ), + const SizedBox(width: 4.0), + SmallYTActionButton( + title: lang.SHARE, + icon: Broken.share, + onPressed: () { + final url = videoInfo?.buildUrl(); + if (url != null) Share.share(url); }, ), - ), - ), - ), - const SizedBox(width: 4.0), - ObxO( - rx: YoutubeController.inst.isTitleExpanded, - builder: (isTitleExpanded) => SmallYTActionButton( - title: (videoDislikeCount ?? 0) < 1 ? lang.DISLIKE : videoDislikeCount?.formatDecimalShort(isTitleExpanded) ?? '?', - icon: Broken.dislike, - onPressed: () {}, - ), - ), - const SizedBox(width: 4.0), - SmallYTActionButton( - title: lang.SHARE, - icon: Broken.share, - onPressed: () { - final url = videoInfo?.url; - if (url != null) Share.share(url); - }, - ), - const SizedBox(width: 4.0), - SmallYTActionButton( - title: lang.REFRESH, - icon: Broken.refresh, - onPressed: () async => await YoutubeController.inst.updateVideoDetails(currentId, forceRequest: true), - ), - const SizedBox(width: 4.0), - Obx( - () { - final audioProgress = YoutubeController.inst.downloadsAudioProgressMap[currentId]?.values.firstOrNull; - final audioPercText = audioProgress?.percentageText(prefix: lang.AUDIO); - final videoProgress = YoutubeController.inst.downloadsVideoProgressMap[currentId]?.values.firstOrNull; - final videoPercText = videoProgress?.percentageText(prefix: lang.VIDEO); + const SizedBox(width: 4.0), + SmallYTActionButton( + title: lang.REFRESH, + icon: Broken.refresh, + onPressed: () async => await YoutubeInfoController.current.updateVideoPage( + currentId, + forceRequestPage: true, + forceRequestComments: true, + ), + ), + const SizedBox(width: 4.0), + Obx( + () { + final audioProgress = YoutubeController.inst.downloadsAudioProgressMap[currentId]?.values.firstOrNull; + final audioPercText = audioProgress?.percentageText(prefix: lang.AUDIO); + final videoProgress = YoutubeController.inst.downloadsVideoProgressMap[currentId]?.values.firstOrNull; + final videoPercText = videoProgress?.percentageText(prefix: lang.VIDEO); - final isDownloading = YoutubeController.inst.isDownloading[currentId]?.values.any((element) => element) == true; + final isDownloading = YoutubeController.inst.isDownloading[currentId]?.values.any((element) => element) == true; - final wasDownloading = videoProgress != null || audioProgress != null; - final icon = (wasDownloading && !isDownloading) - ? Broken.play_circle - : wasDownloading - ? Broken.pause_circle - : downloadedFileExists - ? Broken.tick_circle - : Broken.import; - return SmallYTActionButton( - titleWidget: videoPercText == null && audioPercText == null && isDownloading ? const LoadingIndicator() : null, - title: videoPercText ?? audioPercText ?? lang.DOWNLOAD, - icon: icon, - onLongPress: () async => await showDownloadVideoBottomSheet(videoId: currentId), - onPressed: () async { - if (isDownloading) { - YoutubeController.inst.pauseDownloadTask( - itemsConfig: [], - videosIds: [currentId], - groupName: '', + final wasDownloading = videoProgress != null || audioProgress != null; + final icon = (wasDownloading && !isDownloading) + ? Broken.play_circle + : wasDownloading + ? Broken.pause_circle + : downloadedFileExists + ? Broken.tick_circle + : Broken.import; + return SmallYTActionButton( + titleWidget: videoPercText == null && audioPercText == null && isDownloading ? const LoadingIndicator() : null, + title: videoPercText ?? audioPercText ?? lang.DOWNLOAD, + icon: icon, + onLongPress: () async => await showDownloadVideoBottomSheet(videoId: currentId), + onPressed: () async { + if (isDownloading) { + YoutubeController.inst.pauseDownloadTask( + itemsConfig: [], + videosIds: [currentId], + groupName: '', + ); + } else if (wasDownloading) { + YoutubeController.inst.resumeDownloadTaskForIDs( + videosIds: [currentId], + groupName: '', + ); + } else { + await showDownloadVideoBottomSheet(videoId: currentId); + } + }, ); - } else if (wasDownloading) { - YoutubeController.inst.resumeDownloadTaskForIDs( - videosIds: [currentId], - groupName: '', - ); - } else { - await showDownloadVideoBottomSheet(videoId: currentId); - } - }, - ); - }, - ), - const SizedBox(width: 4.0), - SmallYTActionButton( - title: lang.SAVE, - icon: Broken.music_playlist, - onPressed: () => showAddToPlaylistSheet( - ids: [currentId], - idsNamesLookup: { - currentId: videoInfo?.name ?? '', - }, + }, + ), + const SizedBox(width: 4.0), + SmallYTActionButton( + title: lang.SAVE, + icon: Broken.music_playlist, + onPressed: () => showAddToPlaylistSheet( + ids: [currentId], + idsNamesLookup: { + currentId: videoTitle ?? '', + }, + ), + ), + const SizedBox(width: 4.0), + ], ), ), - const SizedBox(width: 4.0), - ], + ), ), - ), - ), - ), - const SliverPadding(padding: EdgeInsets.only(top: 24.0)), - // --END- buttons + const SliverPadding(padding: EdgeInsets.only(top: 24.0)), + // --END- buttons - // --START- channel - SliverToBoxAdapter( - key: Key("${currentId}_channel"), - child: ShimmerWrapper( - shimmerDurationMS: 550, - shimmerDelayMS: 250, - shimmerEnabled: channelName == null || channelThumbnail == null || channelSubs == null, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () { - final channel = videoChannel ?? Player.inst.currentChannelInfo.value; - 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, + // --START- channel + SliverToBoxAdapter( + key: Key("${currentId}_channel"), + child: ShimmerWrapper( + shimmerDurationMS: 550, + shimmerDelayMS: 250, + shimmerEnabled: channelName == null || channelThumbnail == null || channelSubs == null, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + final ch = channel ?? YoutubeInfoController.current.currentVideoPage.value?.channelInfo; + final chid = ch?.id; + if (chid != null) NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid, channel: ch)); + }, + child: Row( children: [ - FittedBox( - child: Row( + const SizedBox(width: 18.0), + NamidaDummyContainer( + width: 42.0, + height: 42.0, + borderRadius: 100.0, + shimmerEnabled: channelThumbnail == null && (channelID == null || channelID.isEmpty), + child: YoutubeThumbnail( + key: Key("${channelThumbnail}_$channelID"), + isImportantInCache: true, + customUrl: channelThumbnail, + 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: [ - NamidaDummyContainer( - width: 114.0, - height: 12.0, - borderRadius: 4.0, - shimmerEnabled: channelName == null, - child: Text( - channelName ?? '', - style: context.textTheme.displayMedium?.copyWith( - fontSize: 13.5, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, + FittedBox( + child: Row( + children: [ + NamidaDummyContainer( + width: 114.0, + height: 12.0, + borderRadius: 4.0, + shimmerEnabled: channelName == null, + child: Text( + channelName ?? '', + style: mainTextTheme.displayMedium?.copyWith( + fontSize: 13.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ), + ), + if (channelIsVerified) ...[ + const SizedBox(width: 4.0), + const Icon( + Broken.shield_tick, + size: 12.0, + ), + ] + ], ), ), - 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: ObxO( - rx: YoutubeController.inst.isTitleExpanded, - builder: (isTitleExpanded) => Text( - channelSubs == null - ? '? ${lang.SUBSCRIBERS}' - : [ - channelSubs.formatDecimalShort(isTitleExpanded), - channelSubs < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, - ].join(' '), - style: context.textTheme.displaySmall?.copyWith( - fontSize: 12.0, + const SizedBox(height: 2.0), + FittedBox( + child: NamidaDummyContainer( + width: 92.0, + height: 10.0, + borderRadius: 4.0, + shimmerEnabled: channelSubs == null, + child: ObxO( + rx: _isTitleExpanded, + builder: (isTitleExpanded) => Text( + channelSubs == null + ? '? ${lang.SUBSCRIBERS}' + : [ + channelSubs.formatDecimalShort(isTitleExpanded), + channelSubs < 2 ? lang.SUBSCRIBER : lang.SUBSCRIBERS, + ].join(' '), + style: mainTextTheme.displaySmall?.copyWith( + fontSize: 12.0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), + ], ), ), + const SizedBox(width: 12.0), + YTSubscribeButton(channelID: channelID), + const SizedBox(width: 20.0), ], ), ), - const SizedBox(width: 12.0), - YTSubscribeButton(channelIDOrURL: channelIDOrURL), - const SizedBox(width: 20.0), - ], + ), ), ), - ), - ), - ), - const SliverPadding(padding: EdgeInsets.only(top: 4.0)), - // --END-- channel + const SliverPadding(padding: EdgeInsets.only(top: 4.0)), + // --END-- channel - // --SRART-- top comments - const SliverPadding(padding: EdgeInsets.only(top: 4.0)), - Obx( - () { - if (!settings.ytTopComments.valueR) return const SliverToBoxAdapter(child: SizedBox()); - final totalCommentsCount = YoutubeController.inst.currentTotalCommentsCount.valueR; - final comments = YoutubeController.inst.currentComments.valueR; - return SliverToBoxAdapter( - child: comments.isEmpty - ? const SizedBox() - : NamidaInkWell( - key: Key("${currentId}_top_comments_highlight"), - bgColor: Color.alphaBlend(context.theme.scaffoldBackgroundColor.withOpacity(0.4), context.theme.cardColor), - margin: const EdgeInsets.symmetric(horizontal: 18.0), - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - onTap: () { - NamidaNavigator.inst.isInYTCommentsSubpage = true; - NamidaNavigator.inst.ytMiniplayerCommentsPageKey.currentState?.pushPage( - const YTMiniplayerCommentsSubpage(), - maintainState: false, - ); + // --SRART-- top comments + const SliverPadding(padding: EdgeInsets.only(top: 4.0)), + + ObxO( + rx: settings.ytTopComments, + builder: (ytTopComments) { + if (!ytTopComments) return const SliverToBoxAdapter(); + return SliverToBoxAdapter( + child: ObxO( + rx: YoutubeInfoController.current.currentComments, + builder: (comments) => comments == null || comments.isEmpty + ? const SizedBox() + : NamidaInkWell( + key: Key("${currentId}_top_comments_highlight"), + bgColor: Color.alphaBlend(mainTheme.scaffoldBackgroundColor.withOpacity(0.4), mainTheme.cardColor), + margin: const EdgeInsets.symmetric(horizontal: 18.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + onTap: () { + NamidaNavigator.inst.isInYTCommentsSubpage = true; + NamidaNavigator.inst.ytMiniplayerCommentsPageKey.currentState?.pushPage( + const YTMiniplayerCommentsSubpage(), + maintainState: false, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon( + Broken.document, + size: 16.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + [ + lang.COMMENTS, + if (comments.commentsCount != null) comments.commentsCount!.formatDecimalShort(), + ].join(' • '), + style: mainTextTheme.displaySmall, + textAlign: TextAlign.start, + ), + ), + ObxO( + rx: YoutubeInfoController.current.isCurrentCommentsFromCache, + builder: (commFromCache) { + commFromCache ??= false; + return NamidaIconButton( + horizontalPadding: 0.0, + tooltip: commFromCache ? () => lang.CACHE : null, + icon: Broken.refresh, + iconSize: 22.0, + onPressed: () async => await YoutubeInfoController.current.updateCurrentComments( + currentId, + sortType: YoutubeMiniplayerUiController.inst.currentCommentSort.value, + initial: true, + ), + child: commFromCache + ? StackedIcon( + baseIcon: Broken.refresh, + secondaryIcon: Broken.global, + iconSize: 20.0, + secondaryIconSize: 12.0, + baseIconColor: defaultIconColor, + secondaryIconColor: defaultIconColor, + ) + : Icon( + Broken.refresh, + color: defaultIconColor, + size: 20.0, + ), + ); + }, + ), + ], + ), + const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), + ObxO( + rx: YoutubeInfoController.current.isLoadingInitialComments, + builder: (loading) => ShimmerWrapper( + shimmerEnabled: loading, + child: YTCommentCardCompact(comment: loading ? null : comments.items.firstOrNull), + ), + ) + ], + ), + ), + ), + ); + }, + ), + const SliverPadding(padding: EdgeInsets.only(top: 8.0)), + + page == null + ? SliverToBoxAdapter( + key: Key("${currentId}_feed_shimmer"), + child: ShimmerWrapper( + transparent: false, + shimmerEnabled: true, + child: ListView.builder( + padding: EdgeInsets.zero, + key: Key("${currentId}_feedlist_shimmer"), + physics: const NeverScrollableScrollPhysics(), + itemCount: 15, + shrinkWrap: true, + itemBuilder: (context, index) { + return const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailHeight: relatedThumbnailHeight, + thumbnailWidth: relatedThumbnailWidth, + ); + }, + ), + ), + ) + : SliverFixedExtentList.builder( + key: Key("${currentId}_feedlist"), + itemExtent: relatedThumbnailItemExtent, + itemCount: page.relatedVideosResult.items.length, + itemBuilder: (context, index) { + final item = page.relatedVideosResult.items[index]; + return switch (item.runtimeType) { + const (StreamInfoItem) => YoutubeVideoCard( + key: Key((item as StreamInfoItem).id), + thumbnailHeight: relatedThumbnailHeight, + thumbnailWidth: relatedThumbnailWidth, + isImageImportantInCache: false, + video: item, + playlistID: null, + ), + const (StreamInfoItemShort) => YoutubeShortVideoCard( + key: Key("${(item as StreamInfoItemShort?)?.id}"), + thumbnailHeight: relatedThumbnailHeight, + thumbnailWidth: relatedThumbnailWidth, + short: item as StreamInfoItemShort, + playlistID: null, + ), + const (PlaylistInfoItem) => YoutubePlaylistCard( + key: Key((item as PlaylistInfoItem).id), + thumbnailHeight: relatedThumbnailHeight, + thumbnailWidth: relatedThumbnailWidth, + playlist: item, + subtitle: item.subtitle, + playOnTap: true, + ), + _ => const YoutubeVideoCardDummy( + shimmerEnabled: true, + thumbnailHeight: relatedThumbnailHeight, + thumbnailWidth: relatedThumbnailWidth, + ), + }; }, - child: Column( - children: [ - Row( + ), + + const SliverPadding(padding: EdgeInsets.only(top: 12.0)), + + // --START-- Comments + ObxO( + rx: settings.ytTopComments, + builder: (ytTopComments) { + if (ytTopComments) return const SliverToBoxAdapter(); + return ObxO( + rx: YoutubeInfoController.current.currentComments, + builder: (comments) { + final count = comments?.commentsCount; + return SliverToBoxAdapter( + child: Padding( + key: Key("${currentId}_comments_header"), + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), + child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - const Icon( - Broken.document, - size: 16.0, - ), + const Icon(Broken.document), const SizedBox(width: 8.0), Text( [ lang.COMMENTS, - if (totalCommentsCount != null) totalCommentsCount.formatDecimalShort(), + if (count != null) count.formatDecimalShort(), ].join(' • '), - style: context.textTheme.displaySmall, + style: mainTextTheme.displayLarge, textAlign: TextAlign.start, ), const Spacer(), - NamidaIconButton( - horizontalPadding: 0.0, - tooltip: commFromCache ? () => lang.CACHE : null, - icon: Broken.refresh, - iconSize: 22.0, - onPressed: () async => await YoutubeController.inst.updateCurrentComments( - currentId, - forceRequest: ConnectivityController.inst.hasConnection, - ), - child: YoutubeController.inst.isCurrentCommentsFromCache - ? StackedIcon( - baseIcon: Broken.refresh, - secondaryIcon: Broken.global, - iconSize: 20.0, - secondaryIconSize: 12.0, - baseIconColor: defaultIconColor, - secondaryIconColor: defaultIconColor, - ) - : Icon( - Broken.refresh, - color: defaultIconColor, - size: 20.0, - ), - ) + ObxO( + rx: YoutubeInfoController.current.isCurrentCommentsFromCache, + builder: (commFromCache) { + commFromCache ??= false; + return NamidaIconButton( + // key: Key(currentId), + tooltip: commFromCache ? () => lang.CACHE : null, + icon: Broken.refresh, + iconSize: 22.0, + onPressed: () async => await YoutubeInfoController.current.updateCurrentComments( + currentId, + sortType: YoutubeMiniplayerUiController.inst.currentCommentSort.value, + initial: true, + ), + child: commFromCache + ? const StackedIcon( + baseIcon: Broken.refresh, + secondaryIcon: Broken.global, + ) + : Icon( + Broken.refresh, + color: defaultIconColor, + ), + ); + }, + ), ], ), - const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), - ShimmerWrapper( - shimmerEnabled: comments.isNotEmpty && comments.first == null, - child: YTCommentCardCompact(comment: comments.firstOrNull), - ) - ], - ), - ), - ); - }, - ), - const SliverPadding(padding: EdgeInsets.only(top: 8.0)), - - Obx( - () { - final feed = YoutubeController.inst.currentRelatedVideos.valueR; - if (feed.isNotEmpty && feed.first == null) { - return SliverToBoxAdapter( - key: Key("${currentId}_feed_shimmer"), - child: ShimmerWrapper( - transparent: false, - shimmerEnabled: true, - child: ListView.builder( - padding: EdgeInsets.zero, - key: Key("${currentId}_feedlist_shimmer"), - physics: const NeverScrollableScrollPhysics(), - itemCount: feed.length, - shrinkWrap: true, - itemBuilder: (context, index) { - const item = null; - return YoutubeVideoCard( - key: Key("${item == null}_${context.hashCode}"), - thumbnailHeight: relatedThumbnailHeight, - thumbnailWidth: relatedThumbnailWidth, - isImageImportantInCache: false, - video: item, - playlistID: null, + ), ); }, - ), - ), - ); - } - return SliverFixedExtentList.builder( - key: Key("${currentId}_feedlist"), - itemExtent: relatedThumbnailItemExtent, - itemCount: feed.length, - itemBuilder: (context, index) { - final item = feed[index]; - if (item is StreamInfoItem || item == null) { - return YoutubeVideoCard( - key: Key("${item == null}_${context.hashCode}_${(item as StreamInfoItem?)?.id}"), - thumbnailHeight: relatedThumbnailHeight, - thumbnailWidth: relatedThumbnailWidth, - isImageImportantInCache: false, - video: item, - playlistID: null, ); - } else if (item is YoutubePlaylist) { - return YoutubePlaylistCard( - key: Key("${context.hashCode}_${(item).id}"), - playlist: item, - playOnTap: true, - ); - } else if (item is YoutubeChannel) { - return YoutubeChannelCard( - key: Key("${context.hashCode}_${(item as YoutubeChannelCard).channel?.id}"), - channel: item, + }, + ), + ObxO( + rx: settings.ytTopComments, + builder: (ytTopComments) { + if (ytTopComments) return const SliverToBoxAdapter(); + return ObxO( + rx: YoutubeInfoController.current.isLoadingInitialComments, + builder: (loadingInitial) => loadingInitial + ? SliverToBoxAdapter( + key: Key("${currentId}_comments_shimmer"), + child: ShimmerWrapper( + transparent: false, + shimmerEnabled: true, + child: ListView.builder( + // key: Key(currentId), + physics: const NeverScrollableScrollPhysics(), + itemCount: 10, + shrinkWrap: true, + itemBuilder: (context, index) { + const comment = null; + return const YTCommentCard( + key: Key("${comment == null}"), + margin: EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + comment: comment, + ); + }, + ), + ), + ) + : ObxO( + rx: YoutubeInfoController.current.currentComments, + builder: (comments) => comments == null + ? const SliverToBoxAdapter() + : SliverList.builder( + key: Key("${currentId}_comments"), + itemCount: comments.length, + itemBuilder: (context, i) { + final comment = comments[i]; + return YTCommentCard( + key: Key("${comment == null}_${comment?.commentId}"), + margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + comment: comment, + ); + }, + ), + ), ); - } - return const SizedBox(); - }, - ); - }, - ), - const SliverPadding(padding: EdgeInsets.only(top: 12.0)), - - // --START-- Comments - Obx( - () { - if (settings.ytTopComments.valueR) return const SliverToBoxAdapter(child: SizedBox()); - - final totalCommentsCount = YoutubeController.inst.currentTotalCommentsCount.valueR; - return SliverToBoxAdapter( - child: Padding( - key: Key("${currentId}_comments_header"), - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Broken.document), - const SizedBox(width: 8.0), - Text( - [ - lang.COMMENTS, - if (totalCommentsCount != null) totalCommentsCount.formatDecimalShort(), - ].join(' • '), - style: context.textTheme.displayLarge, - textAlign: TextAlign.start, - ), - const Spacer(), - NamidaIconButton( - // key: Key(currentId), - tooltip: commFromCache ? () => lang.CACHE : null, - icon: Broken.refresh, - iconSize: 22.0, - onPressed: () async => await YoutubeController.inst.updateCurrentComments( - currentId, - forceRequest: ConnectivityController.inst.hasConnection, - ), - child: YoutubeController.inst.isCurrentCommentsFromCache - ? const StackedIcon( - baseIcon: Broken.refresh, - secondaryIcon: Broken.global, - ) - : Icon( - Broken.refresh, - color: defaultIconColor, + }, + ), + ObxO( + rx: settings.ytTopComments, + builder: (ytTopComments) { + if (ytTopComments) return const SliverToBoxAdapter(); + return ObxO( + rx: YoutubeInfoController.current.isLoadingMoreComments, + builder: (loadingMoreComments) => loadingMoreComments + ? const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12.0), + child: Center( + child: LoadingIndicator(), + ), ), - ) - ], - ), + ) + : const SliverToBoxAdapter(), + ); + }, ), - ); - }, - ), - Obx( - () { - if (settings.ytTopComments.valueR) return const SliverToBoxAdapter(child: SizedBox()); - - final comments = YoutubeController.inst.currentComments.valueR; - if (comments.isNotEmpty && comments.first == null) { - return SliverToBoxAdapter( - key: Key("${currentId}_comments_shimmer"), - child: ShimmerWrapper( - transparent: false, - shimmerEnabled: true, - child: ListView.builder( - // key: Key(currentId), - physics: const NeverScrollableScrollPhysics(), - itemCount: comments.length, - shrinkWrap: true, - itemBuilder: (context, index) { - const comment = null; - return YTCommentCard( - key: Key("${comment == null}_${context.hashCode}"), - margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - comment: comment, - ); - }, - ), - ), - ); - } - return SliverList.builder( - key: Key("${currentId}_comments"), - itemCount: comments.length, - itemBuilder: (context, i) { - final comment = comments[i]; - return YTCommentCard( - key: Key("${comment == null}_${context.hashCode}_${comment?.commentId}"), - margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - comment: comment, - ); - }, - ); - }, - ), - Obx( - () { - if (settings.ytTopComments.valueR) return const SliverToBoxAdapter(child: SizedBox()); - - final isLoadingComments = YoutubeController.inst.isLoadingComments.valueR; - return isLoadingComments - ? const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(12.0), - child: Center( - child: LoadingIndicator(), - ), - ), - ) - : const SliverToBoxAdapter(child: SizedBox()); - }, - ), - const SliverPadding(padding: EdgeInsets.only(bottom: kYTQueueSheetMinHeight)) - ], - ), - ObxO( - rx: YoutubeController.inst.shouldShowGlowUnderVideo, - builder: (shouldShowGlowUnderVideo) { - const containerHeight = 12.0; - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: shouldShowGlowUnderVideo - ? Stack( - key: const Key('actual_glow'), - children: [ - Container( - height: containerHeight, - color: context.theme.scaffoldBackgroundColor, - ), - Container( - height: containerHeight, - transform: Matrix4.translationValues(0, containerHeight / 2, 0), - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: context.theme.scaffoldBackgroundColor, - spreadRadius: containerHeight * 0.25, - offset: const Offset(0, 0), - blurRadius: 8.0, + const SliverPadding(padding: EdgeInsets.only(bottom: kYTQueueSheetMinHeight)) + ], + ), + ObxO( + rx: _shouldShowGlowUnderVideo, + builder: (shouldShowGlowUnderVideo) { + const containerHeight = 12.0; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: shouldShowGlowUnderVideo + ? Stack( + key: const Key('actual_glow'), + children: [ + Container( + height: containerHeight, + color: mainTheme.scaffoldBackgroundColor, + ), + Container( + height: containerHeight, + transform: Matrix4.translationValues(0, containerHeight / 2, 0), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: mainTheme.scaffoldBackgroundColor, + spreadRadius: containerHeight * 0.25, + offset: const Offset(0, 0), + blurRadius: 8.0, + ), + ], + ), ), ], - ), - ), - ], - ) - : const SizedBox(key: Key('empty_glow')), - ); - }, + ) + : const SizedBox(key: Key('empty_glow')), + ); + }, + ), + ], + ), ), - ], + ), ), - ), + ], ), ), + rightDragAbsorberWidget, + ytMiniplayerQueueChip, + miniplayerDimWidget, // -- dimming + absorbBottomDragWidget, // prevent accidental scroll while performing home gesture ], - ), - ), - rightDragAbsorberWidget, - ytMiniplayerQueueChip, - miniplayerDimWidget, // -- dimming - absorbBottomDragWidget, // prevent accidental scroll while performing home gesture - ], - ); - - final titleChild = Column( - key: Key("${currentId}_title_button1_child"), - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - NamidaDummyContainer( - borderRadius: 4.0, - height: 16.0, - shimmerEnabled: videoInfo == null, - width: context.width - 24.0, - child: Text( - miniTitle ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.displayMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13.5, - ), - ), - ), - const SizedBox(height: 4.0), - NamidaDummyContainer( - borderRadius: 4.0, - height: 10.0, - shimmerEnabled: videoInfo == null, - width: context.width - 24.0 * 2, - child: Text( - miniSubtitle ?? '', - style: context.textTheme.displaySmall?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 13.0, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); + ); - final playPauseButtonChild = Obx( - () { - final isLoading = Player.inst.shouldShowLoadingIndicatorR; - return Stack( - alignment: Alignment.center, - children: [ - if (isLoading) - IgnorePointer( - child: NamidaOpacity( - key: Key("${currentId}_button_loading"), - enabled: true, - opacity: 0.3, - child: ThreeArchedCircle( - key: Key("${currentId}_button_loading_child"), - color: defaultIconColor, - size: 36.0, + final titleChild = Column( + key: Key("${currentId}_title_button1_child"), + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NamidaDummyContainer( + borderRadius: 4.0, + height: 16.0, + shimmerEnabled: page == null, + width: maxWidth - 24.0, + child: Text( + videoTitle ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mainTextTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 13.5, ), ), ), - NamidaIconButton( - horizontalPadding: 0.0, - onPressed: () { - Player.inst.togglePlayPause(); - }, - icon: null, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Player.inst.isPlayingR - ? Icon( - Broken.pause, - color: defaultIconColor, - key: const Key('pause'), - ) - : Icon( - Broken.play, - color: defaultIconColor, - key: const Key('play'), - ), + const SizedBox(height: 4.0), + NamidaDummyContainer( + borderRadius: 4.0, + height: 10.0, + shimmerEnabled: page == null, + width: maxWidth - 24.0 * 2, + child: Text( + channelName ?? '', + style: mainTextTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 13.0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), - ], - ); - }, - ); - final nextButton = NamidaIconButton( - horizontalPadding: 0.0, - icon: Broken.next, - iconColor: defaultIconColor, - onPressed: () { - Player.inst.next(); - }, - ); - - return NamidaYTMiniplayer( - key: MiniPlayerController.inst.ytMiniplayerKey, - duration: const Duration(milliseconds: 1000), - curve: Curves.easeOutExpo, - bottomMargin: 8.0 + (settings.enableBottomNavBar.valueR ? kBottomNavigationBarHeight : 0.0) - 1.0, // -1 is just a clip ensurer. - minHeight: miniplayerHeight, - maxHeight: context.height, - bgColor: miniplayerBGColor, - displayBottomBGLayer: !settings.enableBottomNavBar.valueR, - onDismiss: settings.dismissibleMiniplayer.valueR ? Player.inst.clearQueue : null, - onDismissing: (dismissPercentage) { - Player.inst.setPlayerVolume(dismissPercentage.clamp(0.0, settings.player.volume.value)); - }, - onHeightChange: (percentage) { - MiniPlayerController.inst.animateMiniplayer(percentage); - }, - onAlternativePercentageExecute: () { - VideoController.inst.toggleFullScreenVideoView( - isLocal: false, - setOrientations: false, - ); - }, - builder: (double height, double p) { - final percentage = (p * 2.8).clamp(0.0, 1.0); - final percentageFast = (p * 1.5 - 0.5).clamp(0.0, 1.0); - final inversePerc = 1 - percentage; - final reverseOpacity = (inversePerc * 2.8 - 1.8).clamp(0.0, 1.0); - final finalspace1sb = space1sb * inversePerc; - final finalspace3sb = space3sb * inversePerc; - final finalspace4buttons = space4 * inversePerc; - final finalspace5sb = space5sb * inversePerc; - final finalpadding = 4.0 * inversePerc; - final finalbr = (8.0 * inversePerc).multipliedRadius; - final finalthumbnailWidth = (space2ForThumbnail + context.width * percentage).clamp(space2ForThumbnail, context.width - finalspace1sb - finalspace3sb); - final finalthumbnailHeight = finalthumbnailWidth * 9 / 16; + ], + ); - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - SizedBox(width: finalspace1sb), - Container( - clipBehavior: Clip.antiAlias, - margin: EdgeInsets.symmetric(vertical: finalpadding), - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(finalbr), - ), - width: finalthumbnailWidth, - height: finalthumbnailHeight, - child: NamidaVideoWidget( - isLocal: false, - enableControls: percentage > 0.5, - onMinimizeTap: () => MiniPlayerController.inst.ytMiniplayerKey.currentState?.animateToState(false), - swipeUpToFullscreen: true, - ), - ), - if (reverseOpacity > 0) ...[ - SizedBox(width: finalspace3sb), - SizedBox( - width: (context.width - finalthumbnailWidth - finalspace1sb - finalspace3sb - finalspace4buttons - finalspace5sb).clamp(0, context.width), - child: NamidaOpacity( - key: Key("${currentId}_title_button1"), - enabled: true, - opacity: reverseOpacity, - child: titleChild, - ), - ), - NamidaOpacity( - key: Key("${currentId}_title_button2"), + final playPauseButtonChild = Obx( + () { + final isLoading = Player.inst.shouldShowLoadingIndicatorR; + return Stack( + alignment: Alignment.center, + children: [ + if (isLoading) + IgnorePointer( + child: NamidaOpacity( + key: Key("${currentId}_button_loading"), enabled: true, - opacity: reverseOpacity, - child: SizedBox( - key: Key("${currentId}_title_button2_child"), - width: finalspace4buttons / 2, - height: miniplayerHeight, - child: playPauseButtonChild, + opacity: 0.3, + child: ThreeArchedCircle( + key: Key("${currentId}_button_loading_child"), + color: defaultIconColor, + size: 36.0, ), ), - NamidaOpacity( - key: Key("${currentId}_title_button3"), - enabled: true, - opacity: reverseOpacity, - child: SizedBox( - key: Key("${currentId}_title_button3_child"), - width: finalspace4buttons / 2, - height: miniplayerHeight, - child: nextButton, - ), - ), - SizedBox(width: finalspace5sb), - ] - ], - ), - - // ---- if was in comments subpage, and this gets hidden, the route is popped - // ---- same with [isQueueSheetOpen] - if (NamidaNavigator.inst.isInYTCommentsSubpage || NamidaNavigator.inst.isQueueSheetOpen ? true : percentage > 0) - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - miniplayerBody, - IgnorePointer( - child: ColoredBox( - color: miniplayerBGColor.withOpacity(1 - percentageFast), - ), - ), - ], + ), + NamidaIconButton( + horizontalPadding: 0.0, + onPressed: () { + Player.inst.togglePlayPause(); + }, + icon: null, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Player.inst.isPlayingR + ? Icon( + Broken.pause, + color: defaultIconColor, + key: const Key('pause'), + ) + : Icon( + Broken.play, + color: defaultIconColor, + key: const Key('play'), + ), ), ), - ], - ), - Positioned( - top: finalthumbnailHeight - - (_extraPaddingForYTMiniplayer / 2 * (1 - percentage)) - - (SeekReadyDimensions.barHeight / 2) - - (SeekReadyDimensions.barHeight / 2 * percentage) + - (SeekReadyDimensions.progressBarHeight / 2), - left: 0, - right: 0, - child: seekReadyWidget, + ], + ); + }, + ); + final nextButton = NamidaIconButton( + horizontalPadding: 0.0, + icon: Broken.next, + iconColor: defaultIconColor, + onPressed: () { + Player.inst.next(); + }, + ); + + return ObxO( + rx: settings.enableBottomNavBar, + builder: (enableBottomNavBar) => ObxO( + rx: settings.dismissibleMiniplayer, + builder: (dismissibleMiniplayer) => NamidaYTMiniplayer( + key: MiniPlayerController.inst.ytMiniplayerKey, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeOutExpo, + bottomMargin: 8.0 + (enableBottomNavBar ? kBottomNavigationBarHeight : 0.0) - 1.0, // -1 is just a clip ensurer. + minHeight: miniplayerHeight, + maxHeight: context.height, + bgColor: miniplayerBGColor, + displayBottomBGLayer: !enableBottomNavBar, + onDismiss: dismissibleMiniplayer ? Player.inst.clearQueue : null, + onDismissing: (dismissPercentage) { + Player.inst.setPlayerVolume(dismissPercentage.clamp(0.0, settings.player.volume.value)); + }, + onHeightChange: (percentage) { + MiniPlayerController.inst.animateMiniplayer(percentage); + }, + onAlternativePercentageExecute: () { + VideoController.inst.toggleFullScreenVideoView( + isLocal: false, + setOrientations: false, + ); + }, + builder: (double height, double p) { + final percentage = (p * 2.8).clamp(0.0, 1.0); + final percentageFast = (p * 1.5 - 0.5).clamp(0.0, 1.0); + final inversePerc = 1 - percentage; + final reverseOpacity = (inversePerc * 2.8 - 1.8).clamp(0.0, 1.0); + final finalspace1sb = space1sb * inversePerc; + final finalspace3sb = space3sb * inversePerc; + final finalspace4buttons = space4 * inversePerc; + final finalspace5sb = space5sb * inversePerc; + final finalpadding = 4.0 * inversePerc; + final finalbr = (8.0 * inversePerc).multipliedRadius; + final finalthumbnailWidth = (space2ForThumbnail + maxWidth * percentage).clamp(space2ForThumbnail, maxWidth - finalspace1sb - finalspace3sb); + final finalthumbnailHeight = finalthumbnailWidth * 9 / 16; + + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox(width: finalspace1sb), + Container( + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.symmetric(vertical: finalpadding), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(finalbr), + ), + width: finalthumbnailWidth, + height: finalthumbnailHeight, + child: NamidaVideoWidget( + isLocal: false, + enableControls: percentage > 0.5, + onMinimizeTap: () => MiniPlayerController.inst.ytMiniplayerKey.currentState?.animateToState(false), + swipeUpToFullscreen: true, + ), + ), + if (reverseOpacity > 0) ...[ + SizedBox(width: finalspace3sb), + SizedBox( + width: (maxWidth - finalthumbnailWidth - finalspace1sb - finalspace3sb - finalspace4buttons - finalspace5sb).clamp(0, maxWidth), + child: NamidaOpacity( + key: Key("${currentId}_title_button1"), + enabled: true, + opacity: reverseOpacity, + child: titleChild, + ), + ), + NamidaOpacity( + key: Key("${currentId}_title_button2"), + enabled: true, + opacity: reverseOpacity, + child: SizedBox( + key: Key("${currentId}_title_button2_child"), + width: finalspace4buttons / 2, + height: miniplayerHeight, + child: playPauseButtonChild, + ), + ), + NamidaOpacity( + key: Key("${currentId}_title_button3"), + enabled: true, + opacity: reverseOpacity, + child: SizedBox( + key: Key("${currentId}_title_button3_child"), + width: finalspace4buttons / 2, + height: miniplayerHeight, + child: nextButton, + ), + ), + SizedBox(width: finalspace5sb), + ] + ], + ), + + // ---- if was in comments subpage, and this gets hidden, the route is popped + // ---- same with [isQueueSheetOpen] + if (NamidaNavigator.inst.isInYTCommentsSubpage || NamidaNavigator.inst.isQueueSheetOpen ? true : percentage > 0) + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + miniplayerBody, + IgnorePointer( + child: ColoredBox( + color: miniplayerBGColor.withOpacity(1 - percentageFast), + ), + ), + ], + ), + ), + ], + ), + Positioned( + top: finalthumbnailHeight - + (_extraPaddingForYTMiniplayer / 2 * (1 - percentage)) - + (SeekReadyDimensions.barHeight / 2) - + (SeekReadyDimensions.barHeight / 2 * percentage) + + (SeekReadyDimensions.progressBarHeight / 2), + left: 0, + right: 0, + child: seekReadyWidget, + ), + ], + ); + }, + ), ), - ], - ); - }, + ); + }, + ), ); }, ); diff --git a/lib/youtube/yt_miniplayer_comments_subpage.dart b/lib/youtube/yt_miniplayer_comments_subpage.dart index b7950d32..ce2e1070 100644 --- a/lib/youtube/yt_miniplayer_comments_subpage.dart +++ b/lib/youtube/yt_miniplayer_comments_subpage.dart @@ -12,7 +12,8 @@ import 'package:namida/core/utils.dart'; import 'package:namida/packages/scroll_physics_modified.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_info_controller.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; import 'package:namida/youtube/widgets/yt_comment_card.dart'; class YTMiniplayerCommentsSubpage extends StatefulWidget { @@ -36,14 +37,9 @@ class _YTMiniplayerCommentsSubpageState extends State Text( - [ - lang.COMMENTS, - if (totalCommentsCount != null) totalCommentsCount.formatDecimalShort(), - ].join(' • '), - style: context.textTheme.displayMedium, - textAlign: TextAlign.start, - ), + rx: YoutubeInfoController.current.currentComments, + builder: (comments) { + final count = comments?.commentsCount; + return Text( + [ + lang.COMMENTS, + if (count != null) count.formatDecimalShort(), + ].join(' • '), + style: context.textTheme.displayMedium, + textAlign: TextAlign.start, + ); + }, ), const Spacer(), + // TODO sort types + ObxO( + rx: YoutubeInfoController.current.isCurrentCommentsFromCache, + builder: (isCurrentCommentsFromCache) { + isCurrentCommentsFromCache ??= false; + return NamidaIconButton( tooltip: isCurrentCommentsFromCache ? () => lang.CACHE : null, - sc.jumpTo(0); - await YoutubeController.inst.updateCurrentComments( - currentId ?? '', - forceRequest: ConnectivityController.inst.hasConnection, + icon: Broken.refresh, + iconSize: 22.0, + onPressed: () async { + if (!ConnectivityController.inst.hasConnection) return; + try { + sc.jumpTo(0); + } catch (_) {} + if (currentId != null) { + await YoutubeInfoController.current.updateCurrentComments( + currentId, + sortType: YoutubeMiniplayerUiController.inst.currentCommentSort.value, + initial: true, + ); + } + }, + child: isCurrentCommentsFromCache + ? const StackedIcon( + baseIcon: Broken.refresh, + secondaryIcon: Broken.global, + ) + : Icon( + Broken.refresh, + color: context.defaultIconColor(), + ), ); }, - child: YoutubeController.inst.isCurrentCommentsFromCache - ? const StackedIcon( - baseIcon: Broken.refresh, - secondaryIcon: Broken.global, - ) - : Icon( - Broken.refresh, - color: context.defaultIconColor(), - ), ), const SizedBox(width: 8.0), ], @@ -107,7 +123,7 @@ class _YTMiniplayerCommentsSubpageState extends State await YoutubeController.inst.updateCurrentComments(currentId ?? '', fetchNextOnly: true), + onReachingEnd: () async => currentId == null ? null : await YoutubeInfoController.current.updateCurrentComments(currentId), extend: 400, scrollController: sc, listview: (controller) => CustomScrollView( @@ -115,25 +131,25 @@ class _YTMiniplayerCommentsSubpageState extends State isLoadingComments + rx: YoutubeInfoController.current.isLoadingMoreComments, + builder: (isLoadingMoreComments) => isLoadingMoreComments ? const SliverPadding( padding: EdgeInsets.all(12.0), sliver: SliverToBoxAdapter( @@ -172,7 +194,7 @@ class _YTMiniplayerCommentsSubpageState extends State getVideoCacheStatusIcons({ @@ -166,7 +167,7 @@ class YTUtils { static List getVideoCardMenuItems({ required String videoId, required String? url, - required String? channelUrl, + required String? channelID, required PlaylistID? playlistID, required Map idsNamesLookup, String playlistName = '', @@ -174,7 +175,8 @@ class YTUtils { bool copyUrl = false, }) { final playAfterVid = getPlayerAfterVideo(); - final isCurrentlyPlaying = Player.inst.currentVideo != null && videoId == Player.inst.getCurrentVideoId; + final currentVideo = Player.inst.currentVideo; + final isCurrentlyPlaying = currentVideo != null && videoId == currentVideo.id; return [ NamidaPopupItem( icon: Broken.music_library_2, @@ -204,6 +206,14 @@ class YTUtils { title: lang.COPY, onTap: () => YTUtils().copyCurrentVideoUrl(videoId), ), + if (channelID != null && channelID != '') + NamidaPopupItem( + icon: Broken.user, + title: lang.GO_TO_CHANNEL, + onTap: () { + NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: channelID)); + }, + ), isCurrentlyPlaying ? NamidaPopupItem( icon: Broken.pause, @@ -220,16 +230,6 @@ class YTUtils { Player.inst.playOrPause(0, [YoutubeID(id: videoId, playlistID: playlistID)], QueueSource.others); }, ), - if (channelUrl != '') - NamidaPopupItem( - icon: Broken.user, - title: lang.GO_TO_CHANNEL, - onTap: () { - final chid = YoutubeSubscriptionsController.inst.idOrUrlToChannelID(channelUrl); - if (chid == null) return; - NamidaNavigator.inst.navigateTo(YTChannelSubpage(channelID: chid)); - }, - ), NamidaPopupItem( icon: Broken.next, title: lang.PLAY_NEXT, @@ -270,50 +270,44 @@ class YTUtils { try { final playAfterVideo = player.currentQueue.value[player.latestInsertedIndex] as YoutubeID; final diff = player.latestInsertedIndex - player.currentIndex.value; - final name = YoutubeController.inst.getVideoName(playAfterVideo.id) ?? ''; + final name = YoutubeInfoController.utils.getVideoName(playAfterVideo.id) ?? ''; return (video: playAfterVideo, diff: diff, name: name); } catch (_) {} } return null; } - static Map getMetadataInitialMap(String id, VideoInfo? info, {bool autoExtract = true}) { + static Map getMetadataInitialMap(String id, VideoStreamInfo? info, {bool autoExtract = true}) { String removeTopic(String text) { const topic = '- Topic'; final startIndex = (text.length - topic.length).withMinimum(0); return text.replaceFirst(topic, '', startIndex).trimAll(); } - final date = info?.date; - final description = info?.description; - String? title = info?.name; - String? artist = info?.uploaderName; + final date = info?.publishedAt.date; + final description = info?.availableDescription; + String? title = info?.title; + String? artist = info?.channelName; String? album; if (autoExtract) { - final splitted = info?.name?.splitArtistAndTitle(); + final splitted = info?.title.splitArtistAndTitle(); if (splitted != null && splitted.$1 != null && splitted.$2 != null) { title = splitted.$2; artist = splitted.$1; } - final uploaderName = info?.uploaderName; + final uploaderName = info?.channelName; if (uploaderName != null) album = removeTopic(uploaderName); } if (artist != null) artist = removeTopic(artist); - String? synopsis; - if (description != null) { - try { - synopsis = HtmlParser.parseHTML(description).text; - } catch (_) {} - } return { FFMPEGTagField.title: title, FFMPEGTagField.artist: artist, FFMPEGTagField.album: album, - FFMPEGTagField.comment: YoutubeController.inst.getYoutubeLink(id), + FFMPEGTagField.comment: YTUrlUtils.buildVideoUrl(id), FFMPEGTagField.year: date == null ? null : DateFormat('yyyyMMdd').format(date), - FFMPEGTagField.synopsis: synopsis, + FFMPEGTagField.synopsis: description, }; } @@ -334,12 +328,10 @@ class YTUtils { } static Future onYoutubeHistoryPlaylistTap({ - double initialScrollOffset = 0, - int? indexToHighlight, - int? dayOfHighLight, + required HistoryScrollInfo scrollInfo, + required double initialScrollOffset, }) async { - YoutubeHistoryController.inst.indexToHighlight.value = indexToHighlight; - YoutubeHistoryController.inst.dayOfHighLight.value = dayOfHighLight; + YoutubeHistoryController.inst.highlightedItem.value = scrollInfo; void jump() => YoutubeHistoryController.inst.scrollController.jumpTo(initialScrollOffset); @@ -553,10 +545,11 @@ class YTUtils { totalSizeToDelete.value -= size; } }, - trailing: Obx( - () => NamidaCheckMark( + trailing: ObxO( + rx: tempFilesDelete, + builder: (deletetemp) => NamidaCheckMark( size: 16.0, - active: tempFilesDelete.valueR, + active: deletetemp, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index ba5192f7..91dddf10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,10 +84,10 @@ dependencies: path: packages/on_audio_query # ---- Video Indexing & Playback ---- - ffmpeg_kit_flutter_min: "6.0.3-LTS" - newpipeextractor_dart: + ffmpeg_kit_flutter_min: "6.0.0-LTS" + youtipie: git: - url: https://github.com/MSOB7YY/NewPipeExtractor_Dart + url: https://github.com/namidaco/youtipie # ---- Image Utilities ---- palette_generator: ^0.3.3+2