From 3ad4bb451810eab70bf9bec275ff5be3560209d3 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 23 Nov 2025 00:26:40 +0800 Subject: [PATCH] :recycle: Rebuild the call --- assets/i18n/en-US.json | 3 +- lib/pods/chat/call.dart | 7 +- lib/pods/chat/call.g.dart | 2 +- lib/pods/site_files.g.dart | 149 +++++++- lib/route.dart | 9 - lib/screens/chat/call.dart | 82 +---- lib/screens/chat/room.dart | 15 +- .../chat/widgets/message_item_wrapper.dart | 2 +- lib/widgets/chat/call_button.dart | 30 +- lib/widgets/chat/call_content.dart | 77 ++++ lib/widgets/chat/call_overlay.dart | 336 +++++++++++++----- macos/Podfile.lock | 6 - 12 files changed, 525 insertions(+), 193 deletions(-) create mode 100644 lib/widgets/chat/call_content.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 52a42efc..b4c6004c 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1342,5 +1342,6 @@ "folder": "Folder", "clearCompleted": "Clear Completed", "contentCantEmpty": "Content cannot be empty", - "features": "Features" + "features": "Features", + "unnamed": "Unnamed" } \ No newline at end of file diff --git a/lib/pods/chat/call.dart b/lib/pods/chat/call.dart index b17af936..1aa5b59a 100644 --- a/lib/pods/chat/call.dart +++ b/lib/pods/chat/call.dart @@ -212,7 +212,11 @@ class CallNotifier extends _$CallNotifier { String? _roomId; String? get roomId => _roomId; - Future joinRoom(String roomId) async { + SnChatRoom? _chatRoom; + SnChatRoom? get chatRoom => _chatRoom; + + Future joinRoom(SnChatRoom room) async { + var roomId = room.id; if (_roomId == roomId && _room != null) { talker.info('[Call] Call skipped. Already has data'); return; @@ -223,6 +227,7 @@ class CallNotifier extends _$CallNotifier { } } _roomId = roomId; + _chatRoom = room; if (_room != null) { await _room!.disconnect(); await _room!.dispose(); diff --git a/lib/pods/chat/call.g.dart b/lib/pods/chat/call.g.dart index 57a92fde..1b07c32b 100644 --- a/lib/pods/chat/call.g.dart +++ b/lib/pods/chat/call.g.dart @@ -6,7 +6,7 @@ part of 'call.dart'; // RiverpodGenerator // ************************************************************************** -String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281'; +String _$callNotifierHash() => r'2caee30f42315e539cb4df17c0d464ceed41ffa0'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/pods/site_files.g.dart b/lib/pods/site_files.g.dart index c2933ef1..dc1cff83 100644 --- a/lib/pods/site_files.g.dart +++ b/lib/pods/site_files.g.dart @@ -158,7 +158,7 @@ class _SiteFilesProviderElement String? get path => (origin as SiteFilesProvider).path; } -String _$siteFileContentHash() => r'bb820f0fe5bdca55efb08beee97aa38d09be04a7'; +String _$siteFileContentHash() => r'b594ad4f8c54555e742ece94ee001092cb2f83d1'; /// See also [siteFileContent]. @ProviderFor(siteFileContent) @@ -300,5 +300,152 @@ class _SiteFileContentProviderElement String get relativePath => (origin as SiteFileContentProvider).relativePath; } +String _$siteFileContentRawHash() => + r'd0331c30698a9f4b90fe9b79273ff5914fa46616'; + +/// See also [siteFileContentRaw]. +@ProviderFor(siteFileContentRaw) +const siteFileContentRawProvider = SiteFileContentRawFamily(); + +/// See also [siteFileContentRaw]. +class SiteFileContentRawFamily extends Family> { + /// See also [siteFileContentRaw]. + const SiteFileContentRawFamily(); + + /// See also [siteFileContentRaw]. + SiteFileContentRawProvider call({ + required String siteId, + required String relativePath, + }) { + return SiteFileContentRawProvider( + siteId: siteId, + relativePath: relativePath, + ); + } + + @override + SiteFileContentRawProvider getProviderOverride( + covariant SiteFileContentRawProvider provider, + ) { + return call(siteId: provider.siteId, relativePath: provider.relativePath); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'siteFileContentRawProvider'; +} + +/// See also [siteFileContentRaw]. +class SiteFileContentRawProvider extends AutoDisposeFutureProvider { + /// See also [siteFileContentRaw]. + SiteFileContentRawProvider({ + required String siteId, + required String relativePath, + }) : this._internal( + (ref) => siteFileContentRaw( + ref as SiteFileContentRawRef, + siteId: siteId, + relativePath: relativePath, + ), + from: siteFileContentRawProvider, + name: r'siteFileContentRawProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$siteFileContentRawHash, + dependencies: SiteFileContentRawFamily._dependencies, + allTransitiveDependencies: + SiteFileContentRawFamily._allTransitiveDependencies, + siteId: siteId, + relativePath: relativePath, + ); + + SiteFileContentRawProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.siteId, + required this.relativePath, + }) : super.internal(); + + final String siteId; + final String relativePath; + + @override + Override overrideWith( + FutureOr Function(SiteFileContentRawRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SiteFileContentRawProvider._internal( + (ref) => create(ref as SiteFileContentRawRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + siteId: siteId, + relativePath: relativePath, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SiteFileContentRawProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SiteFileContentRawProvider && + other.siteId == siteId && + other.relativePath == relativePath; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, siteId.hashCode); + hash = _SystemHash.combine(hash, relativePath.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SiteFileContentRawRef on AutoDisposeFutureProviderRef { + /// The parameter `siteId` of this provider. + String get siteId; + + /// The parameter `relativePath` of this provider. + String get relativePath; +} + +class _SiteFileContentRawProviderElement + extends AutoDisposeFutureProviderElement + with SiteFileContentRawRef { + _SiteFileContentRawProviderElement(super.provider); + + @override + String get siteId => (origin as SiteFileContentRawProvider).siteId; + @override + String get relativePath => + (origin as SiteFileContentRawProvider).relativePath; +} + // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/route.dart b/lib/route.dart index adf06868..8f24dfae 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -32,7 +32,6 @@ import 'package:island/screens/account/me/account_settings.dart'; import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room_detail.dart'; -import 'package:island/screens/chat/call.dart'; import 'package:island/screens/chat/search_messages.dart'; import 'package:island/screens/thought/think.dart'; import 'package:island/screens/creators/hub.dart'; @@ -119,14 +118,6 @@ final routerProvider = Provider((ref) { return ArticleEditScreen(id: id); }, ), - GoRoute( - name: 'chatCall', - path: '/chat/:id/call', - builder: (context, state) { - final id = state.pathParameters['id']!; - return CallScreen(roomId: id); - }, - ), GoRoute( name: 'logs', path: '/logs', diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index dc1ce75a..27398ae2 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -3,30 +3,31 @@ import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/chat.dart'; import 'package:island/pods/chat/call.dart'; import 'package:island/talker.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/call_button.dart'; +import 'package:island/widgets/chat/call_content.dart'; import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/alert.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:styled_widget/styled_widget.dart'; class CallScreen extends HookConsumerWidget { - final String roomId; - const CallScreen({super.key, required this.roomId}); + final SnChatRoom room; + const CallScreen({super.key, required this.room}); @override Widget build(BuildContext context, WidgetRef ref) { - final ongoingCall = ref.watch(ongoingCallProvider(roomId)); + final ongoingCall = ref.watch(ongoingCallProvider(room.id)); final callState = ref.watch(callNotifierProvider); final callNotifier = ref.watch(callNotifierProvider.notifier); useEffect(() { talker.info('[Call] Joining the call...'); - callNotifier.joinRoom(roomId).catchError((_) { + callNotifier.joinRoom(room).catchError((_) { showConfirmAlert( 'Seems there already has a call connected, do you want override it?', 'Call already connected', @@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget { talker.info('[Call] Joining the call... with overrides'); callNotifier.disconnect(); callNotifier.dispose(); - callNotifier.joinRoom(roomId); + callNotifier.joinRoom(room); }); }); return null; @@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget { onPressed: () { callNotifier.disconnect(); callNotifier.dispose(); - callNotifier.joinRoom(roomId); + callNotifier.joinRoom(room); }, child: Text('retry').tr(), ), @@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget { ) : Column( children: [ - Expanded( - child: Builder( - builder: (context) { - if (!callState.isConnected) { - return const Center( - child: CircularProgressIndicator(), - ); - } - if (callNotifier.participants.isEmpty) { - return const Center( - child: Text('No participants in call'), - ); - } - - final participants = callNotifier.participants; - if (allAudioOnly) { - // Audio-only: show avatars in a compact row - return Center( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - for (final live in participants) - SpeakingRippleAvatar( - live: live, - size: 72, - ).padding(horizontal: 4), - ], - ), - ), - ); - } - - // Stage view: show main speaker(s) large, others in row - final mainSpeakers = - participants - .where( - (p) => p - .remoteParticipant - .trackPublications - .values - .any( - (pub) => - pub.track != null && - pub.kind == TrackType.VIDEO, - ), - ) - .toList(); - if (mainSpeakers.isEmpty && participants.isNotEmpty) { - mainSpeakers.add(participants.first); - } - return Column( - children: [ - for (final speaker in mainSpeakers) - Expanded( - child: CallParticipantTile(live: speaker), - ), - ], - ); - }, - ), - ), + Expanded(child: CallContent()), CallControlsBar(), Gap(MediaQuery.of(context).padding.bottom + 16), ], diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index dbdad01e..ed2a4c88 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -738,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget { ), ), actions: [ - AudioCallButton(roomId: id), + chatRoom.when( + data: (data) => AudioCallButton(room: data!), + error: (err, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), IconButton( icon: const Icon(Icons.more_vert), onPressed: () async { @@ -839,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget { left: 0, right: 0, top: 0, - child: CallOverlayBar().padding(horizontal: 8, top: 12), + child: chatRoom.when( + data: + (data) => CallOverlayBar( + room: data!, + ).padding(horizontal: 8, top: 12), + error: (_, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), ), if (isSyncing) Positioned( diff --git a/lib/screens/chat/widgets/message_item_wrapper.dart b/lib/screens/chat/widgets/message_item_wrapper.dart index 0670dfdd..aca91aea 100644 --- a/lib/screens/chat/widgets/message_item_wrapper.dart +++ b/lib/screens/chat/widgets/message_item_wrapper.dart @@ -57,7 +57,7 @@ class MessageItemWrapper extends HookConsumerWidget { skipError: true, data: (identity) => _buildContent(context, identity), loading: () => _buildLoading(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); if (!shouldAnimate) { diff --git a/lib/widgets/chat/call_button.dart b/lib/widgets/chat/call_button.dart index 9d0508b4..ca13a1c7 100644 --- a/lib/widgets/chat/call_button.dart +++ b/lib/widgets/chat/call_button.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/chat.dart'; @@ -28,12 +28,12 @@ Future ongoingCall(Ref ref, String roomId) async { } class AudioCallButton extends HookConsumerWidget { - final String roomId; - const AudioCallButton({super.key, required this.roomId}); + final SnChatRoom room; + const AudioCallButton({super.key, required this.room}); @override Widget build(BuildContext context, WidgetRef ref) { - final ongoingCall = ref.watch(ongoingCallProvider(roomId)); + final ongoingCall = ref.watch(ongoingCallProvider(room.id)); final callState = ref.watch(callNotifierProvider); final callNotifier = ref.read(callNotifierProvider.notifier); final isLoading = useState(false); @@ -42,10 +42,9 @@ class AudioCallButton extends HookConsumerWidget { Future handleJoin() async { isLoading.value = true; try { - await apiClient.post('/sphere/chat/realtime/$roomId'); - if (context.mounted) { - context.pushNamed('chatCall', pathParameters: {'id': roomId}); - } + await apiClient.post('/sphere/chat/realtime/${room.id}'); + // Just join the room, the overlay will handle the UI + await callNotifier.joinRoom(room); } catch (e) { showErrorAlert(e); } finally { @@ -56,7 +55,7 @@ class AudioCallButton extends HookConsumerWidget { Future handleEnd() async { isLoading.value = true; try { - await apiClient.delete('/sphere/chat/realtime/$roomId'); + await apiClient.delete('/sphere/chat/realtime/${room.id}'); callNotifier.dispose(); // Clean up call resources } catch (e) { showErrorAlert(e); @@ -94,9 +93,14 @@ class AudioCallButton extends HookConsumerWidget { return IconButton( icon: const Icon(Icons.call), tooltip: 'Join Ongoing Call', - onPressed: () { - if (context.mounted) { - context.pushNamed('chatCall', pathParameters: {'id': roomId}); + onPressed: () async { + isLoading.value = true; + try { + await callNotifier.joinRoom(room); + } catch (e) { + showErrorAlert(e); + } finally { + isLoading.value = false; } }, ); @@ -105,7 +109,7 @@ class AudioCallButton extends HookConsumerWidget { // Show join/start call button return IconButton( icon: const Icon(Icons.call), - tooltip: 'Start/Join Call', + tooltip: 'Start Call', onPressed: handleJoin, ); } diff --git a/lib/widgets/chat/call_content.dart b/lib/widgets/chat/call_content.dart new file mode 100644 index 00000000..47dc2acf --- /dev/null +++ b/lib/widgets/chat/call_content.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/chat/call.dart'; +import 'package:island/widgets/chat/call_participant_tile.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class CallContent extends HookConsumerWidget { + const CallContent({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final callState = ref.watch(callNotifierProvider); + final callNotifier = ref.watch(callNotifierProvider.notifier); + + if (!callState.isConnected) { + return const Center(child: CircularProgressIndicator()); + } + if (callNotifier.participants.isEmpty) { + return const Center(child: Text('No participants in call')); + } + + final participants = callNotifier.participants; + final allAudioOnly = participants.every( + (p) => + !(p.hasVideo && + p.remoteParticipant.trackPublications.values.any( + (pub) => + pub.track != null && + pub.kind == TrackType.VIDEO && + !pub.muted && + !pub.isDisposed, + )), + ); + + if (allAudioOnly) { + // Audio-only: show avatars in a compact row + return Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + for (final live in participants) + SpeakingRippleAvatar( + live: live, + size: 72, + ).padding(horizontal: 4), + ], + ), + ), + ); + } + + // Stage view: show main speaker(s) large, others in row + final mainSpeakers = + participants + .where( + (p) => p.remoteParticipant.trackPublications.values.any( + (pub) => pub.track != null && pub.kind == TrackType.VIDEO, + ), + ) + .toList(); + if (mainSpeakers.isEmpty && participants.isNotEmpty) { + mainSpeakers.add(participants.first); + } + return Column( + children: [ + for (final speaker in mainSpeakers) + Expanded(child: CallParticipantTile(live: speaker)), + ], + ); + } +} diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index 2688c48a..43dc2495 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -1,11 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/account.dart'; +import 'package:island/models/chat.dart'; import 'package:island/pods/chat/call.dart'; +import 'package:island/pods/userinfo.dart'; +import 'package:island/screens/chat/call.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/chat/call_button.dart'; +import 'package:island/widgets/chat/call_content.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -13,7 +19,8 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:livekit_client/livekit_client.dart'; class CallControlsBar extends HookConsumerWidget { - const CallControlsBar({super.key}); + final bool isCompact; + const CallControlsBar({super.key, this.isCompact = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,11 +28,14 @@ class CallControlsBar extends HookConsumerWidget { final callNotifier = ref.read(callNotifierProvider.notifier); return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + padding: EdgeInsets.symmetric( + horizontal: isCompact ? 12 : 20, + vertical: isCompact ? 8 : 16, + ), child: Wrap( alignment: WrapAlignment.center, - runSpacing: 16, - spacing: 16, + runSpacing: isCompact ? 12 : 16, + spacing: isCompact ? 12 : 16, children: [ _buildCircularButtonWithDropdown( context: context, @@ -73,12 +83,15 @@ class CallControlsBar extends HookConsumerWidget { (innerContext) => Column( mainAxisSize: MainAxisSize.min, children: [ + const Gap(24), ListTile( leading: const Icon(Symbols.logout, fill: 1), title: Text('callLeave').tr(), onTap: () { callNotifier.disconnect(); - Navigator.of(context).pop(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } Navigator.of(innerContext).pop(); }, ), @@ -96,7 +109,9 @@ class CallControlsBar extends HookConsumerWidget { ); callNotifier.dispose(); if (context.mounted) { - Navigator.of(context).pop(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } Navigator.of(innerContext).pop(); } } catch (err) { @@ -124,12 +139,14 @@ class CallControlsBar extends HookConsumerWidget { required Color backgroundColor, Color? iconColor, }) { + final size = isCompact ? 40.0 : 56.0; + final iconSize = isCompact ? 20.0 : 24.0; return Container( - width: 56, - height: 56, + width: size, + height: size, decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), child: IconButton( - icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), + icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize), onPressed: onPressed, ), ); @@ -145,41 +162,51 @@ class CallControlsBar extends HookConsumerWidget { Color? iconColor, String? deviceType, // 'videoinput' or 'audioinput' }) { + final size = isCompact ? 40.0 : 56.0; + final iconSize = isCompact ? 20.0 : 24.0; return Stack( + clipBehavior: Clip.none, children: [ Container( - width: 56, - height: 56, + width: size, + height: size, decoration: BoxDecoration( color: backgroundColor, shape: BoxShape.circle, ), child: IconButton( - icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), + icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize), onPressed: onPressed, ), ), if (hasDropdown && deviceType != null) Positioned( - bottom: 4, - right: 4, - child: GestureDetector( - onTap: () => _showDeviceSelectionDialog(context, ref, deviceType), - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.8), - shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withOpacity(0.3), - width: 0.5, + bottom: 0, + right: isCompact ? 0 : -4, + child: Material( + color: + Colors + .transparent, // Make Material transparent to show underlying color + child: InkWell( + onTap: + () => _showDeviceSelectionDialog(context, ref, deviceType), + borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2), + child: Container( + width: isCompact ? 16 : 24, + height: isCompact ? 16 : 24, + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.8), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 0.5, + ), + ), + child: Icon( + Icons.arrow_drop_down, + color: Colors.white, + size: isCompact ? 12 : 20, ), - ), - child: Icon( - Icons.arrow_drop_down, - color: Colors.white, - size: 12, ), ), ), @@ -279,34 +306,133 @@ class CallControlsBar extends HookConsumerWidget { } class CallOverlayBar extends HookConsumerWidget { - const CallOverlayBar({super.key}); + final SnChatRoom room; + const CallOverlayBar({super.key, required this.room}); @override Widget build(BuildContext context, WidgetRef ref) { final callState = ref.watch(callNotifierProvider); final callNotifier = ref.read(callNotifierProvider.notifier); - // Only show if connected and not on the call screen - if (!callState.isConnected) return const SizedBox.shrink(); + final ongoingCall = ref.watch(ongoingCallProvider(room.id)); + // State for overlay mode: compact or preview + // Default to true (preview mode) so user sees video immediately after joining + final isExpanded = useState(true); + + // If connected, show active call UI + if (callState.isConnected) { + return _buildActiveCallOverlay( + context, + ref, + callState, + callNotifier, + isExpanded, + ); + } + + // If not connected but there is an ongoing call, show join prompt + if (ongoingCall.value != null) { + return _buildJoinPrompt(context, ref); + } + + return const SizedBox.shrink(); + } + + Widget _buildJoinPrompt(BuildContext context, WidgetRef ref) { + final isLoading = useState(false); + + return Card( + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.videocam, + color: Theme.of(context).colorScheme.onPrimary, + size: 20, + ), + ), + const Gap(12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Call in progress').bold(), + Text('Tap to join', style: Theme.of(context).textTheme.bodySmall), + ], + ), + const Spacer(), + if (isLoading.value) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ).padding(right: 8) + else + FilledButton.icon( + onPressed: () async { + isLoading.value = true; + try { + // Just join the room, don't navigate + await ref.read(callNotifierProvider.notifier).joinRoom(room); + } catch (e) { + showErrorAlert(e); + } finally { + isLoading.value = false; + } + }, + icon: const Icon(Icons.call, size: 18), + label: const Text('Join'), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ).padding(all: 12), + ); + } + + String _getChatRoomName(SnChatRoom? room, SnAccount currentUser) { + if (room == null) return 'unnamed'.tr(); + return room.name ?? + (room.members ?? []) + .where((element) => element.id != currentUser.id) + .map((element) => element.account.nick) + .first; + } + + Widget _buildActiveCallOverlay( + BuildContext context, + WidgetRef ref, + CallState callState, + CallNotifier callNotifier, + ValueNotifier isExpanded, + ) { final lastSpeaker = callNotifier.participants .where( (element) => element.remoteParticipant.lastSpokeAt != null, ) .isEmpty - ? callNotifier.participants.first + ? callNotifier.participants.firstOrNull : callNotifier.participants .where( (element) => element.remoteParticipant.lastSpokeAt != null, ) .fold( - callNotifier.participants.first, + callNotifier.participants.firstOrNull, (value, element) => element.remoteParticipant.lastSpokeAt != null && - (value.remoteParticipant.lastSpokeAt == null || + (value?.remoteParticipant.lastSpokeAt == null || element.remoteParticipant.lastSpokeAt! .compareTo( - value + value! .remoteParticipant .lastSpokeAt!, ) > @@ -315,11 +441,70 @@ class CallOverlayBar extends HookConsumerWidget { : value, ); - final actionButtonStyle = ButtonStyle( - minimumSize: const MaterialStatePropertyAll(Size(24, 24)), - ); + if (lastSpeaker == null) return const SizedBox.shrink(); + final userInfo = ref.watch(userInfoProvider).value!; + + // Preview Mode (Expanded) + if (isExpanded.value) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + const Gap(4), + Text(_getChatRoomName(callNotifier.chatRoom, userInfo)), + const Gap(4), + Text(formatDuration(callState.duration)).bold(), + const Spacer(), + IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + icon: const Icon(Icons.fullscreen), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CallScreen(room: room), + ), + ); + }, + tooltip: 'Full Screen', + ), + IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + icon: const Icon(Icons.expand_less), + onPressed: () => isExpanded.value = false, + tooltip: 'Collapse', + ), + ], + ).padding(horizontal: 12, vertical: 8), + // Video Preview + Container( + height: 200, + width: double.infinity, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const CallContent(), + ), + const CallControlsBar( + isCompact: true, + ).padding(vertical: 8, horizontal: 16), + ], + ), + ); + } + + // Compact Mode return GestureDetector( + onTap: () => isExpanded.value = true, child: Card( margin: EdgeInsets.zero, child: Row( @@ -328,30 +513,32 @@ class CallOverlayBar extends HookConsumerWidget { Expanded( child: Row( children: [ - Builder( - builder: (context) { - if (callNotifier.localParticipant == null) { - return CircularProgressIndicator().center(); - } - return SizedBox( - width: 40, - height: 40, - child: - SpeakingRippleAvatar( - live: lastSpeaker, - size: 36, - ).center(), - ); - }, + SizedBox( + width: 40, + height: 40, + child: + SpeakingRippleAvatar( + live: lastSpeaker, + size: 36, + ).center(), ), const Gap(8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('@${lastSpeaker.participant.identity}').bold(), - Text( - formatDuration(callState.duration), - style: Theme.of(context).textTheme.bodySmall, + Row( + spacing: 4, + children: [ + Text( + _getChatRoomName(callNotifier.chatRoom, userInfo), + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + formatDuration(callState.duration), + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), ], ), @@ -361,41 +548,20 @@ class CallOverlayBar extends HookConsumerWidget { IconButton( icon: Icon( callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, + size: 20, ), onPressed: () { callNotifier.toggleMicrophone(); }, - style: actionButtonStyle, ), IconButton( - icon: Icon( - callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, - ), - onPressed: () { - callNotifier.toggleCamera(); - }, - style: actionButtonStyle, - ), - IconButton( - icon: Icon( - callState.isScreenSharing - ? Icons.stop_screen_share - : Icons.screen_share, - ), - onPressed: () { - callNotifier.toggleScreenShare(context); - }, - style: actionButtonStyle, + icon: const Icon(Icons.expand_more), + onPressed: () => isExpanded.value = true, + tooltip: 'Expand', ), ], - ).padding(all: 16), + ).padding(all: 12), ), - onTap: () { - context.pushNamed( - 'chatCall', - pathParameters: {'id': callNotifier.roomId!}, - ); - }, ); } } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f7b30b06..5029c27e 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -102,8 +102,6 @@ PODS: - OrderedSet (~> 6.0.3) - flutter_local_notifications (0.0.1): - FlutterMacOS - - flutter_platform_alert (0.0.1): - - FlutterMacOS - flutter_secure_storage_macos (6.1.3): - FlutterMacOS - flutter_timezone (0.1.0): @@ -269,7 +267,6 @@ DEPENDENCIES: - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) @@ -349,8 +346,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos - flutter_platform_alert: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos flutter_timezone: @@ -432,7 +427,6 @@ SPEC CHECKSUMS: FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 - flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_timezone: d272288c69082ad571630e0d17140b3d6b93dc0c flutter_udid: 00c09e022fd527fd39fef97670b220f2ae8190e7