diff --git a/lib/modules/root/use_downloader_dialogs.dart b/lib/modules/root/use_downloader_dialogs.dart new file mode 100644 index 00000000..e2f91043 --- /dev/null +++ b/lib/modules/root/use_downloader_dialogs.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; + +void useDownloaderDialogs(WidgetRef ref) { + final context = useContext(); + final showingDialogCompleter = useRef(Completer()..complete()); + final downloader = ref.watch(downloadManagerProvider); + + useEffect(() { + downloader.onFileExists = (track) async { + if (!context.mounted) return false; + + if (!showingDialogCompleter.value.isCompleted) { + await showingDialogCompleter.value.future; + } + + final replaceAll = ref.read(replaceDownloadedFileState); + + if (replaceAll != null) return replaceAll; + + showingDialogCompleter.value = Completer(); + + if (context.mounted) { + final result = await showDialog( + context: context, + builder: (context) => ReplaceDownloadedDialog( + track: track, + ), + ) ?? + false; + + showingDialogCompleter.value.complete(); + return result; + } + + // it'll never reach here as root_app is always mounted + return false; + }; + return null; + }, [downloader]); +} diff --git a/lib/modules/root/use_global_subscriptions.dart b/lib/modules/root/use_global_subscriptions.dart new file mode 100644 index 00000000..e0e4dae7 --- /dev/null +++ b/lib/modules/root/use_global_subscriptions.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/server/routes/connect.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; +import 'package:spotube/utils/service_utils.dart'; + +void useGlobalSubscriptions(WidgetRef ref) { + final context = useContext(); + final theme = Theme.of(context); + final connectRoutes = ref.watch(serverConnectRoutesProvider); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + ServiceUtils.checkForUpdates(context, ref); + }); + + StreamSubscription? audioPlayerSubscription; + bool pausedByStream = false; + + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((connected) async { + audioPlayerSubscription?.cancel(); + + /// Pausing or resuming based on connectivity to avoid MPV skipping + /// audio while retrying to connect + if (audioPlayer.currentIndex >= 0) { + if (connected && audioPlayer.isPaused && pausedByStream) { + await audioPlayer.resume(); + pausedByStream = false; + } else if (!connected && audioPlayer.isPlaying) { + if ((audioPlayer.bufferedPosition - const Duration(seconds: 1)) <= + audioPlayer.position) { + await audioPlayer.pause(); + pausedByStream = true; + } else { + audioPlayerSubscription = + audioPlayer.positionStream.listen((position) async { + if (ConnectionCheckerService.instance.isConnectedSync) return; + + final bufferedPosition = + audioPlayer.bufferedPosition - const Duration(seconds: 1); + final duration = + audioPlayer.duration - const Duration(seconds: 1); + + if (bufferedPosition <= position || position >= duration) { + audioPlayer.pause(); + pausedByStream = true; + } + }); + } + } + } + + // Show notification for connection related issues + if (!context.mounted) return; + + showToast( + context: context, + location: ToastLocation.bottomCenter, + builder: (context, overlay) { + if (connected) { + return SurfaceCard( + child: Basic( + leading: const Icon(SpotubeIcons.wifi), + title: Text(context.l10n.connection_restored), + ), + ); + } + + return SurfaceCard( + fillColor: theme.colorScheme.destructive, + filled: true, + child: Basic( + leading: Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.destructiveForeground, + ), + trailing: Text( + context.l10n.you_are_offline, + style: TextStyle( + color: theme.colorScheme.destructiveForeground, + ), + ), + ), + ); + }, + ); + }), + connectRoutes.connectClientStream.listen((clientOrigin) { + if (!context.mounted) return; + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + fillColor: Colors.yellow[600], + filled: true, + child: Basic( + leading: const Icon( + SpotubeIcons.error, + color: Colors.black, + ), + title: Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), + ), + ), + ); + }, + ); + }) + ]; + + return () { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }; + }, []); +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index cdb56910..2a1bf088 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,26 +1,20 @@ -import 'dart:async'; - import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/framework/app_pop_scope.dart'; -import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/sidebar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; +import 'package:spotube/modules/root/use_downloader_dialogs.dart'; +import 'package:spotube/modules/root/use_global_subscriptions.dart'; import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/glance/glance.dart'; -import 'package:spotube/provider/server/routes/connect.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; class RootApp extends HookConsumerWidget { final Widget child; @@ -31,138 +25,14 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - - final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider); - - final connectRoutes = ref.watch(serverConnectRoutesProvider); - - ref.listen(glanceProvider, (_, __) {}); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - ServiceUtils.checkForUpdates(context, ref); - }); - - final subscriptions = [ - ConnectionCheckerService.instance.onConnectivityChanged - .listen((status) { - if (!context.mounted) return; - if (status) { - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - fillColor: theme.colorScheme.primary, - child: Row( - children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.primaryForeground, - ), - const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - ); - }, - ); - } else { - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - fillColor: theme.colorScheme.destructive, - child: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.destructiveForeground, - ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), - ], - ), - ); - }, - ); - } - }), - connectRoutes.connectClientStream.listen((clientOrigin) { - if (!context.mounted) return; - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - fillColor: Colors.yellow[600], - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - SpotubeIcons.error, - color: Colors.black, - ), - const SizedBox(width: 10), - Text( - context.l10n.connect_client_alert(clientOrigin), - style: const TextStyle(color: Colors.black), - ), - ], - ), - ); - }, - ); - }) - ]; - - return () { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }; - }, []); - - useEffect(() { - downloader.onFileExists = (track) async { - if (!context.mounted) return false; - - if (!showingDialogCompleter.value.isCompleted) { - await showingDialogCompleter.value.future; - } - - final replaceAll = ref.read(replaceDownloadedFileState); - - if (replaceAll != null) return replaceAll; - - showingDialogCompleter.value = Completer(); - - if (context.mounted) { - final result = await showDialog( - context: context, - builder: (context) => ReplaceDownloadedDialog( - track: track, - ), - ) ?? - false; - - showingDialogCompleter.value.complete(); - return result; - } - - // it'll never reach here as root_app is always mounted - return false; - }; - return null; - }, [downloader]); - - // checks for latest version of the application - - useEndlessPlayback(ref); - final backgroundColor = Theme.of(context).colorScheme.background; final brightness = Theme.of(context).brightness; + ref.listen(glanceProvider, (_, __) {}); + useGlobalSubscriptions(ref); + useDownloaderDialogs(ref); + useEndlessPlayback(ref); + useEffect(() { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 170cbb12..aa93bd4f 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -45,7 +45,7 @@ class AudioPlayerNotifier extends Notifier { var playlist = await database.select(database.playlistTable).getSingleOrNull(); - var medias = await database.select(database.playlistMediaTable).get(); + final medias = await database.select(database.playlistMediaTable).get(); if (playlist == null) { await database.into(database.playlistTable).insert( diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index 86765671..478890df 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; import 'package:spotube/services/logger/logger.dart'; @@ -34,6 +35,10 @@ class ConnectionCheckerService with WidgetsBindingObserver { AppLogger.reportError(e, stack); } }); + + Connectivity().onConnectivityChanged.listen((event) async { + await isConnected; + }); } @override @@ -77,8 +82,9 @@ class ConnectionCheckerService with WidgetsBindingObserver { } return interfaces.any( - (interface) => - vpnNames.any((name) => interface.name.toLowerCase().contains(name)), + (interface) => vpnNames.any( + (name) => interface.name.toLowerCase().contains(name), + ), ); } @@ -109,10 +115,10 @@ class ConnectionCheckerService with WidgetsBindingObserver { Future get isConnected async { final connected = await _isConnected(); - isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } + isConnectedSync = connected; return connected; } diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 0b5ee71b..1bafb705 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -82,14 +82,11 @@ class YoutubeSourcedTrack extends SourcedTrack { ); } final item = await youtubeClient.videos.get(cachedSource.sourceId); - final manifest = await youtubeClient.videos.streamsClient - .getManifest( - cachedSource.sourceId, - ) - .timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); + final manifest = await youtubeClient.videos.streamsClient.getManifest( + cachedSource.sourceId, + requireWatchPage: false, + ytClients: [YoutubeApiClient.tv], + ); return YoutubeSourcedTrack( ref: ref, siblings: [], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 608a854e..b92d7882 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import audio_service import audio_session import bonsoir_darwin +import connectivity_plus import desktop_webview_window import device_info_plus import file_selector_macos @@ -33,6 +34,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index bdf530f1..44da1f81 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -8,6 +8,9 @@ PODS: - bonsoir_darwin (0.0.1): - Flutter - FlutterMacOS + - connectivity_plus (0.0.1): + - Flutter + - FlutterMacOS - desktop_webview_window (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): @@ -79,6 +82,7 @@ DEPENDENCIES: - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`) - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) @@ -116,6 +120,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos bonsoir_darwin: :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin desktop_webview_window: :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos device_info_plus: @@ -166,6 +172,7 @@ SPEC CHECKSUMS: audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d diff --git a/pubspec.lock b/pubspec.lock index 2fa1d8ab..09bc1361 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -375,6 +375,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "8a68739d3ee113e51ad35583fdf9ab82c55d09d693d3c39da1aebab87c938412" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -1542,6 +1558,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c20981b..b9e44021 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: bonsoir: ^5.1.10 cached_network_image: ^3.3.1 collection: ^1.18.0 + connectivity_plus: ^6.1.2 desktop_webview_window: git: path: packages/desktop_webview_window diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 42fa2129..d1bee122 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AppLinksPluginCApi")); BonsoirWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); DesktopWebviewWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cf14ec52..32c8a634 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links bonsoir_windows + connectivity_plus desktop_webview_window file_selector_windows flutter_inappwebview_windows