♻️ 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

@@ -1342,5 +1342,6 @@
"folder": "Folder", "folder": "Folder",
"clearCompleted": "Clear Completed", "clearCompleted": "Clear Completed",
"contentCantEmpty": "Content cannot be empty", "contentCantEmpty": "Content cannot be empty",
"features": "Features" "features": "Features",
"unnamed": "Unnamed"
} }

View File

@@ -212,7 +212,11 @@ class CallNotifier extends _$CallNotifier {
String? _roomId; String? _roomId;
String? get roomId => _roomId; String? get roomId => _roomId;
Future<void> joinRoom(String roomId) async { SnChatRoom? _chatRoom;
SnChatRoom? get chatRoom => _chatRoom;
Future<void> joinRoom(SnChatRoom room) async {
var roomId = room.id;
if (_roomId == roomId && _room != null) { if (_roomId == roomId && _room != null) {
talker.info('[Call] Call skipped. Already has data'); talker.info('[Call] Call skipped. Already has data');
return; return;
@@ -223,6 +227,7 @@ class CallNotifier extends _$CallNotifier {
} }
} }
_roomId = roomId; _roomId = roomId;
_chatRoom = room;
if (_room != null) { if (_room != null) {
await _room!.disconnect(); await _room!.disconnect();
await _room!.dispose(); await _room!.dispose();

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281'; String _$callNotifierHash() => r'2caee30f42315e539cb4df17c0d464ceed41ffa0';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@@ -158,7 +158,7 @@ class _SiteFilesProviderElement
String? get path => (origin as SiteFilesProvider).path; String? get path => (origin as SiteFilesProvider).path;
} }
String _$siteFileContentHash() => r'bb820f0fe5bdca55efb08beee97aa38d09be04a7'; String _$siteFileContentHash() => r'b594ad4f8c54555e742ece94ee001092cb2f83d1';
/// See also [siteFileContent]. /// See also [siteFileContent].
@ProviderFor(siteFileContent) @ProviderFor(siteFileContent)
@@ -300,5 +300,152 @@ class _SiteFileContentProviderElement
String get relativePath => (origin as SiteFileContentProvider).relativePath; 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<AsyncValue<String>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'siteFileContentRawProvider';
}
/// See also [siteFileContentRaw].
class SiteFileContentRawProvider extends AutoDisposeFutureProvider<String> {
/// 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<String> 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<String> 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<String> {
/// The parameter `siteId` of this provider.
String get siteId;
/// The parameter `relativePath` of this provider.
String get relativePath;
}
class _SiteFileContentRawProviderElement
extends AutoDisposeFutureProviderElement<String>
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: 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 // 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

View File

@@ -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/chat.dart';
import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.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/chat/search_messages.dart';
import 'package:island/screens/thought/think.dart'; import 'package:island/screens/thought/think.dart';
import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/hub.dart';
@@ -119,14 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ArticleEditScreen(id: id); return ArticleEditScreen(id: id);
}, },
), ),
GoRoute(
name: 'chatCall',
path: '/chat/:id/call',
builder: (context, state) {
final id = state.pathParameters['id']!;
return CallScreen(roomId: id);
},
),
GoRoute( GoRoute(
name: 'logs', name: 'logs',
path: '/logs', path: '/logs',

View File

@@ -3,30 +3,31 @@ import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.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_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CallScreen extends HookConsumerWidget { class CallScreen extends HookConsumerWidget {
final String roomId; final SnChatRoom room;
const CallScreen({super.key, required this.roomId}); const CallScreen({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier); final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() { useEffect(() {
talker.info('[Call] Joining the call...'); talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) { callNotifier.joinRoom(room).catchError((_) {
showConfirmAlert( showConfirmAlert(
'Seems there already has a call connected, do you want override it?', 'Seems there already has a call connected, do you want override it?',
'Call already connected', 'Call already connected',
@@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget {
talker.info('[Call] Joining the call... with overrides'); talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(room);
}); });
}); });
return null; return null;
@@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(room);
}, },
child: Text('retry').tr(), child: Text('retry').tr(),
), ),
@@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget {
) )
: Column( : Column(
children: [ children: [
Expanded( Expanded(child: CallContent()),
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),
),
],
);
},
),
),
CallControlsBar(), CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16), Gap(MediaQuery.of(context).padding.bottom + 16),
], ],

View File

@@ -738,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
actions: [ actions: [
AudioCallButton(roomId: id), chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (err, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () async { onPressed: () async {
@@ -839,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
left: 0, left: 0,
right: 0, right: 0,
top: 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) if (isSyncing)
Positioned( Positioned(

View File

@@ -57,7 +57,7 @@ class MessageItemWrapper extends HookConsumerWidget {
skipError: true, skipError: true,
data: (identity) => _buildContent(context, identity), data: (identity) => _buildContent(context, identity),
loading: () => _buildLoading(), loading: () => _buildLoading(),
error: (_, __) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
); );
if (!shouldAnimate) { if (!shouldAnimate) {

View File

@@ -1,6 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
@@ -28,12 +28,12 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
} }
class AudioCallButton extends HookConsumerWidget { class AudioCallButton extends HookConsumerWidget {
final String roomId; final SnChatRoom room;
const AudioCallButton({super.key, required this.roomId}); const AudioCallButton({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
final isLoading = useState(false); final isLoading = useState(false);
@@ -42,10 +42,9 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleJoin() async { Future<void> handleJoin() async {
isLoading.value = true; isLoading.value = true;
try { try {
await apiClient.post('/sphere/chat/realtime/$roomId'); await apiClient.post('/sphere/chat/realtime/${room.id}');
if (context.mounted) { // Just join the room, the overlay will handle the UI
context.pushNamed('chatCall', pathParameters: {'id': roomId}); await callNotifier.joinRoom(room);
}
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
} finally { } finally {
@@ -56,7 +55,7 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleEnd() async { Future<void> handleEnd() async {
isLoading.value = true; isLoading.value = true;
try { try {
await apiClient.delete('/sphere/chat/realtime/$roomId'); await apiClient.delete('/sphere/chat/realtime/${room.id}');
callNotifier.dispose(); // Clean up call resources callNotifier.dispose(); // Clean up call resources
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
@@ -94,9 +93,14 @@ class AudioCallButton extends HookConsumerWidget {
return IconButton( return IconButton(
icon: const Icon(Icons.call), icon: const Icon(Icons.call),
tooltip: 'Join Ongoing Call', tooltip: 'Join Ongoing Call',
onPressed: () { onPressed: () async {
if (context.mounted) { isLoading.value = true;
context.pushNamed('chatCall', pathParameters: {'id': roomId}); 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 // Show join/start call button
return IconButton( return IconButton(
icon: const Icon(Icons.call), icon: const Icon(Icons.call),
tooltip: 'Start/Join Call', tooltip: 'Start Call',
onPressed: handleJoin, 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:easy_localization/easy_localization.dart';
import 'package:flutter/material.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:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/pods/network.dart';
import 'package:island/widgets/alert.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/chat/call_participant_tile.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.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'; import 'package:livekit_client/livekit_client.dart';
class CallControlsBar extends HookConsumerWidget { class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key}); final bool isCompact;
const CallControlsBar({super.key, this.isCompact = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -21,11 +28,14 @@ class CallControlsBar extends HookConsumerWidget {
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), padding: EdgeInsets.symmetric(
horizontal: isCompact ? 12 : 20,
vertical: isCompact ? 8 : 16,
),
child: Wrap( child: Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
runSpacing: 16, runSpacing: isCompact ? 12 : 16,
spacing: 16, spacing: isCompact ? 12 : 16,
children: [ children: [
_buildCircularButtonWithDropdown( _buildCircularButtonWithDropdown(
context: context, context: context,
@@ -73,12 +83,15 @@ class CallControlsBar extends HookConsumerWidget {
(innerContext) => Column( (innerContext) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Gap(24),
ListTile( ListTile(
leading: const Icon(Symbols.logout, fill: 1), leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(), title: Text('callLeave').tr(),
onTap: () { onTap: () {
callNotifier.disconnect(); callNotifier.disconnect();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
}
Navigator.of(innerContext).pop(); Navigator.of(innerContext).pop();
}, },
), ),
@@ -96,7 +109,9 @@ class CallControlsBar extends HookConsumerWidget {
); );
callNotifier.dispose(); callNotifier.dispose();
if (context.mounted) { if (context.mounted) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
}
Navigator.of(innerContext).pop(); Navigator.of(innerContext).pop();
} }
} catch (err) { } catch (err) {
@@ -124,12 +139,14 @@ class CallControlsBar extends HookConsumerWidget {
required Color backgroundColor, required Color backgroundColor,
Color? iconColor, Color? iconColor,
}) { }) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Container( return Container(
width: 56, width: size,
height: 56, height: size,
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton( child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed, onPressed: onPressed,
), ),
); );
@@ -145,29 +162,38 @@ class CallControlsBar extends HookConsumerWidget {
Color? iconColor, Color? iconColor,
String? deviceType, // 'videoinput' or 'audioinput' String? deviceType, // 'videoinput' or 'audioinput'
}) { }) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Stack( return Stack(
clipBehavior: Clip.none,
children: [ children: [
Container( Container(
width: 56, width: size,
height: 56, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed, onPressed: onPressed,
), ),
), ),
if (hasDropdown && deviceType != null) if (hasDropdown && deviceType != null)
Positioned( Positioned(
bottom: 4, bottom: 0,
right: 4, right: isCompact ? 0 : -4,
child: GestureDetector( child: Material(
onTap: () => _showDeviceSelectionDialog(context, ref, deviceType), 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( child: Container(
width: 16, width: isCompact ? 16 : 24,
height: 16, height: isCompact ? 16 : 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.8), color: backgroundColor.withOpacity(0.8),
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -179,7 +205,8 @@ class CallControlsBar extends HookConsumerWidget {
child: Icon( child: Icon(
Icons.arrow_drop_down, Icons.arrow_drop_down,
color: Colors.white, color: Colors.white,
size: 12, size: isCompact ? 12 : 20,
),
), ),
), ),
), ),
@@ -279,34 +306,133 @@ class CallControlsBar extends HookConsumerWidget {
} }
class CallOverlayBar extends HookConsumerWidget { class CallOverlayBar extends HookConsumerWidget {
const CallOverlayBar({super.key}); final SnChatRoom room;
const CallOverlayBar({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider); final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
// Only show if connected and not on the call screen final ongoingCall = ref.watch(ongoingCallProvider(room.id));
if (!callState.isConnected) return const SizedBox.shrink();
// 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 = final lastSpeaker =
callNotifier.participants callNotifier.participants
.where( .where(
(element) => element.remoteParticipant.lastSpokeAt != null, (element) => element.remoteParticipant.lastSpokeAt != null,
) )
.isEmpty .isEmpty
? callNotifier.participants.first ? callNotifier.participants.firstOrNull
: callNotifier.participants : callNotifier.participants
.where( .where(
(element) => element.remoteParticipant.lastSpokeAt != null, (element) => element.remoteParticipant.lastSpokeAt != null,
) )
.fold( .fold(
callNotifier.participants.first, callNotifier.participants.firstOrNull,
(value, element) => (value, element) =>
element.remoteParticipant.lastSpokeAt != null && element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null || (value?.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt! element.remoteParticipant.lastSpokeAt!
.compareTo( .compareTo(
value value!
.remoteParticipant .remoteParticipant
.lastSpokeAt!, .lastSpokeAt!,
) > ) >
@@ -315,11 +441,70 @@ class CallOverlayBar extends HookConsumerWidget {
: value, : value,
); );
final actionButtonStyle = ButtonStyle( if (lastSpeaker == null) return const SizedBox.shrink();
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
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( return GestureDetector(
onTap: () => isExpanded.value = true,
child: Card( child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Row( child: Row(
@@ -328,12 +513,7 @@ class CallOverlayBar extends HookConsumerWidget {
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Builder( SizedBox(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: child:
@@ -341,14 +521,19 @@ class CallOverlayBar extends HookConsumerWidget {
live: lastSpeaker, live: lastSpeaker,
size: 36, size: 36,
).center(), ).center(),
);
},
), ),
const Gap(8), const Gap(8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('@${lastSpeaker.participant.identity}').bold(), Text('@${lastSpeaker.participant.identity}').bold(),
Row(
spacing: 4,
children: [
Text(
_getChatRoomName(callNotifier.chatRoom, userInfo),
style: Theme.of(context).textTheme.bodySmall,
),
Text( Text(
formatDuration(callState.duration), formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
@@ -357,45 +542,26 @@ class CallOverlayBar extends HookConsumerWidget {
), ),
], ],
), ),
],
),
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
size: 20,
), ),
onPressed: () { onPressed: () {
callNotifier.toggleMicrophone(); callNotifier.toggleMicrophone();
}, },
style: actionButtonStyle,
), ),
IconButton( IconButton(
icon: Icon( icon: const Icon(Icons.expand_more),
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, onPressed: () => isExpanded.value = true,
), tooltip: 'Expand',
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare(context);
},
style: actionButtonStyle,
), ),
], ],
).padding(all: 16), ).padding(all: 12),
), ),
onTap: () {
context.pushNamed(
'chatCall',
pathParameters: {'id': callNotifier.roomId!},
);
},
); );
} }
} }

View File

@@ -102,8 +102,6 @@ PODS:
- OrderedSet (~> 6.0.3) - OrderedSet (~> 6.0.3)
- flutter_local_notifications (0.0.1): - flutter_local_notifications (0.0.1):
- FlutterMacOS - FlutterMacOS
- flutter_platform_alert (0.0.1):
- FlutterMacOS
- flutter_secure_storage_macos (6.1.3): - flutter_secure_storage_macos (6.1.3):
- FlutterMacOS - FlutterMacOS
- flutter_timezone (0.1.0): - flutter_timezone (0.1.0):
@@ -269,7 +267,6 @@ DEPENDENCIES:
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/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_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_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
- flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/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 :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_local_notifications: flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos :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: flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
flutter_timezone: flutter_timezone:
@@ -432,7 +427,6 @@ SPEC CHECKSUMS:
FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_timezone: d272288c69082ad571630e0d17140b3d6b93dc0c flutter_timezone: d272288c69082ad571630e0d17140b3d6b93dc0c
flutter_udid: 00c09e022fd527fd39fef97670b220f2ae8190e7 flutter_udid: 00c09e022fd527fd39fef97670b220f2ae8190e7