♻️ Rebuild the call

This commit is contained in:
2025-11-23 00:26:40 +08:00
parent 53f0dcb825
commit 3ad4bb4518
12 changed files with 525 additions and 193 deletions

View File

@@ -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<SnRealtimeCall?> 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<void> 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<void> 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,
);
}

View File

@@ -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)),
],
);
}
}

View File

@@ -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<bool> 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!},
);
},
);
}
}