diff --git a/lib/main.dart b/lib/main.dart index f6ce10ca..e6ad4610 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -340,6 +340,7 @@ class IslandApp extends HookConsumerWidget { final apiClient = ref.read(apiClientProvider); subscribePushNotification(apiClient); initializeLocalNotifications(); + ref.read(audioSessionProvider); ref.read(notificationSfxProvider); ref.read(messageSfxProvider); final wsNotifier = ref.read(websocketStateProvider.notifier); diff --git a/lib/pods/audio.dart b/lib/pods/audio.dart index 3399323a..57b3ad4c 100644 --- a/lib/pods/audio.dart +++ b/lib/pods/audio.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:island/pods/config.dart'; +import 'package:audio_session/audio_session.dart'; final sfxPlayerProvider = Provider((ref) { final player = AudioPlayer(); @@ -10,6 +11,22 @@ final sfxPlayerProvider = Provider((ref) { return player; }); +Future _configureAudioSession() async { + final session = await AudioSession.instance; + await session.configure( + const AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.mixWithOthers, + ), + ); + await session.setActive(true); +} + +final audioSessionProvider = FutureProvider((ref) async { + await _configureAudioSession(); +}); + final notificationSfxProvider = FutureProvider((ref) async { final player = ref.watch(sfxPlayerProvider); await player.setVolume(0.75); @@ -31,6 +48,7 @@ void playNotificationSfx(WidgetRef ref) { final settings = ref.read(appSettingsProvider); if (!settings.soundEffects) return; final player = ref.read(sfxPlayerProvider); + player.seek(Duration.zero); player.play(); } @@ -38,5 +56,6 @@ void playMessageSfx(WidgetRef ref) { final settings = ref.read(appSettingsProvider); if (!settings.soundEffects) return; final player = ref.read(sfxPlayerProvider); + player.seek(Duration.zero); player.play(); } diff --git a/lib/widgets/notification_item.dart b/lib/widgets/notification_item.dart index 63509e4b..f152da36 100644 --- a/lib/widgets/notification_item.dart +++ b/lib/widgets/notification_item.dart @@ -1,7 +1,4 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/notification.dart'; @@ -25,125 +22,81 @@ class NotificationItemWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final animationController = useAnimationController( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 250), - ); - final isDismissed = useState(false); - - final slideTween = Tween( - begin: Offset(isDesktop ? 1.0 : 0.0, -0.2), - end: Offset.zero, - ).chain(CurveTween(curve: Curves.easeOutCubic)); - - final fadeTween = Tween( - begin: 0.0, - end: 1.0, - ).chain(CurveTween(curve: Curves.easeOut)); - - useEffect(() { - animationController.forward(); - return null; - }, []); - - useEffect(() { - if (isDismissed.value) return null; - final timer = Timer(item.duration, () async { - if (!isDismissed.value) { - isDismissed.value = true; - await animationController.reverse(); - onDismiss(); - } - }); - return () => timer.cancel(); - }, [item.duration, isDismissed.value]); - - return AnimatedBuilder( - animation: animationController, - builder: (context, child) { - return Transform.translate( - offset: slideTween.evaluate(animationController), - child: Opacity( - opacity: fadeTween.evaluate(animationController), - child: child, - ), - ); - }, - child: GestureDetector( - onTap: () { - if (item.notification.meta['action_uri'] != null) { - var uri = item.notification.meta['action_uri'] as String; - if (uri.startsWith('solian://')) { - uri = uri.replaceFirst('solian://', ''); - } - if (uri.startsWith('/')) { - rootNavigatorKey.currentContext?.push( - item.notification.meta['action_uri'], - ); - } else { - launchUrlString(uri); - } + return GestureDetector( + onTap: () { + if (item.notification.meta['action_uri'] != null) { + var uri = item.notification.meta['action_uri'] as String; + if (uri.startsWith('solian://')) { + uri = uri.replaceFirst('solian://', ''); } - }, - onHorizontalDragEnd: isDesktop - ? (details) { - if (details.primaryVelocity! > 100 && !isDismissed.value) { - isDismissed.value = true; - animationController.reverse().then((_) => onDismiss()); - } + if (uri.startsWith('/')) { + rootNavigatorKey.currentContext?.push( + item.notification.meta['action_uri'], + ); + } else { + launchUrlString(uri); + } + } + }, + onHorizontalDragEnd: isDesktop + ? (details) { + if (details.primaryVelocity! > 100) { + onDismiss(); } - : null, - child: Card( - elevation: 4, - margin: EdgeInsets.zero, - color: Theme.of(context).colorScheme.surfaceContainer, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + } + : null, + child: Card( + elevation: 4, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop ? 400 : double.infinity, ), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: isDesktop ? 400 : double.infinity, - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (item.notification.meta['pfp'] != null) - ProfilePictureWidget( - fileId: item.notification.meta['pfp'], - radius: 12, - ).padding(right: 12, top: 2) - else - Icon( - Symbols.info, - color: Theme.of(context).colorScheme.primary, - size: 24, - ).padding(right: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (item.notification.meta['pfp'] != null) + ProfilePictureWidget( + fileId: item.notification.meta['pfp'], + radius: 12, + ).padding(right: 12, top: 2) + else + Icon( + Symbols.info, + color: Theme.of(context).colorScheme.primary, + size: 24, + ).padding(right: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.notification.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (item.notification.content.isNotEmpty) Text( - item.notification.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + item.notification.content, + style: Theme.of(context).textTheme.bodyMedium, ), - if (item.notification.content.isNotEmpty) - Text( - item.notification.content, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (item.notification.subtitle.isNotEmpty) - Text( - item.notification.subtitle, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), + if (item.notification.subtitle.isNotEmpty) + Text( + item.notification.subtitle, + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/widgets/notification_overlay.dart b/lib/widgets/notification_overlay.dart index 80562dc8..1fc72260 100644 --- a/lib/widgets/notification_overlay.dart +++ b/lib/widgets/notification_overlay.dart @@ -1,8 +1,14 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/notification.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/notification_item.dart'; +import 'package:styled_widget/styled_widget.dart'; class NotificationOverlay extends HookConsumerWidget { const NotificationOverlay({super.key}); @@ -11,65 +17,115 @@ class NotificationOverlay extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final notifications = ref.watch(notificationStateProvider); final isDesktop = isWideScreen(context); - final safeTop = MediaQuery.of(context).padding.top; + final devicePadding = MediaQuery.paddingOf(context); + final topOffset = + devicePadding.top + + ((!kIsWeb && + (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) + ? 40 + : 16); if (notifications.isEmpty) { return const SizedBox.shrink(); } - if (isDesktop) { - return Positioned( - top: safeTop + 16, - right: 16, - left: null, - bottom: null, - width: 420, + return Positioned( + top: topOffset, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, child: Column( mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.end + : CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: notifications.reversed.toList().asMap().entries.map(( - entry, - ) { - final item = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: NotificationItemWidget( - item: item, - isDesktop: true, - onDismiss: () { - ref.read(notificationStateProvider.notifier).remove(item.id); - }, - ), - ); - }).toList(), - ), - ); - } else { - return Positioned( - top: safeTop + 12, - left: 16, - right: 16, - bottom: null, - child: Stack( children: notifications.asMap().entries.map((entry) { - final index = entry.key; final item = entry.value; - return Positioned( - top: index * 12.0, - left: 0, - right: 0, - child: NotificationItemWidget( - item: item, - isDesktop: false, - onDismiss: () { - ref.read(notificationStateProvider.notifier).remove(item.id); - }, - ), + return AnimatedNotificationItem( + key: Key(item.id), + item: item, + isDesktop: isDesktop, + onDismiss: () { + ref.read(notificationStateProvider.notifier).remove(item.id); + }, ); }).toList(), ), - ); - } + ), + ); + } +} + +class AnimatedNotificationItem extends HookConsumerWidget { + final NotificationItem item; + final VoidCallback onDismiss; + final bool isDesktop; + + const AnimatedNotificationItem({ + super.key, + required this.item, + required this.onDismiss, + required this.isDesktop, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final animationController = useAnimationController( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 250), + ); + final isDismissed = useState(false); + + final slideTween = Tween( + begin: Offset(isDesktop ? 1.0 : 0.0, 0), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)); + + final fadeTween = Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOut)); + + useEffect(() { + animationController.forward(); + return null; + }, []); + + useEffect(() { + if (isDismissed.value) return null; + final timer = Timer(item.duration, () async { + if (!isDismissed.value) { + isDismissed.value = true; + await animationController.reverse(); + onDismiss(); + } + }); + return () => timer.cancel(); + }, [item.duration, isDismissed.value]); + + return AnimatedBuilder( + animation: animationController, + builder: (context, child) { + return Transform.translate( + offset: slideTween.evaluate(animationController), + child: Opacity( + opacity: fadeTween.evaluate(animationController), + child: child, + ), + ); + }, + child: Padding( + padding: isDesktop + ? const EdgeInsets.only(bottom: 12, right: 16) + : const EdgeInsets.only(bottom: 12, left: 16, right: 16), + child: NotificationItemWidget( + item: item, + isDesktop: isDesktop, + onDismiss: () {}, + ).width(isDesktop ? 340 : MediaQuery.sizeOf(context).width - 40), + ), + ); } } diff --git a/pubspec.lock b/pubspec.lock index ac76873a..cd1893ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -98,7 +98,7 @@ packages: source: hosted version: "2.13.0" audio_session: - dependency: transitive + dependency: "direct main" description: name: audio_session sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" diff --git a/pubspec.yaml b/pubspec.yaml index 8ceca3f8..9ec169ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -177,6 +177,7 @@ dependencies: flutter_app_intents: ^0.7.0 video_thumbnail: ^0.5.6 just_audio: ^0.10.5 + audio_session: ^0.2.2 dev_dependencies: flutter_test: