From 200cf3ec80b039a8de879fbad85233f2cebcc40b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 28 Dec 2025 00:40:20 +0800 Subject: [PATCH] :lipstick: Better call UI --- lib/pods/chat/call.dart | 16 +- lib/pods/chat/call.freezed.dart | 47 ++-- lib/pods/chat/call.g.dart | 2 +- lib/pods/chat/messages_notifier.g.dart | 2 +- lib/screens/chat/call.dart | 74 +++---- lib/widgets/chat/call_content.dart | 144 ++++++++++--- lib/widgets/chat/call_overlay.dart | 226 ++++++++++---------- lib/widgets/chat/call_participant_tile.dart | 95 +++++--- lib/widgets/post/post_shared.g.dart | 2 +- 9 files changed, 370 insertions(+), 238 deletions(-) diff --git a/lib/pods/chat/call.dart b/lib/pods/chat/call.dart index 87ad3488..cc67c3ae 100644 --- a/lib/pods/chat/call.dart +++ b/lib/pods/chat/call.dart @@ -16,6 +16,8 @@ import 'package:island/talker.dart'; part 'call.g.dart'; part 'call.freezed.dart'; +enum ViewMode { grid, stage } + String formatDuration(Duration duration) { String negativeSign = duration.isNegative ? '-' : ''; String twoDigits(int n) => n.toString().padLeft(2, "0"); @@ -33,6 +35,7 @@ sealed class CallState with _$CallState { required bool isScreenSharing, required bool isSpeakerphone, @Default(Duration(seconds: 0)) Duration duration, + @Default(ViewMode.grid) ViewMode viewMode, String? error, }) = _CallState; } @@ -84,6 +87,7 @@ class CallNotifier extends _$CallNotifier { isCameraEnabled: false, isScreenSharing: false, isSpeakerphone: true, + viewMode: ViewMode.grid, ); } @@ -258,8 +262,8 @@ class CallNotifier extends _$CallNotifier { duration: Duration( milliseconds: (DateTime.now().millisecondsSinceEpoch - - (ongoingCall?.createdAt.millisecondsSinceEpoch ?? - DateTime.now().millisecondsSinceEpoch)), + (ongoingCall?.createdAt.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch)), ), ); }); @@ -418,6 +422,14 @@ class CallNotifier extends _$CallNotifier { return participantsVolumes[live.remoteParticipant.sid] ?? 1; } + void toggleViewMode() { + state = state.copyWith( + viewMode: state.viewMode == ViewMode.grid + ? ViewMode.stage + : ViewMode.grid, + ); + } + void dispose() { state = state.copyWith( error: null, diff --git a/lib/pods/chat/call.freezed.dart b/lib/pods/chat/call.freezed.dart index f1254f9b..d7b23d7a 100644 --- a/lib/pods/chat/call.freezed.dart +++ b/lib/pods/chat/call.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$CallState implements DiagnosticableTreeMixin { - bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; String? get error; + bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; ViewMode get viewMode; String? get error; /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,21 +26,21 @@ $CallStateCopyWith get copyWith => _$CallStateCopyWithImpl void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'CallState')) - ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); + ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error)); } @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error)); } @override -int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); +int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; + return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)'; } @@ -51,7 +51,7 @@ abstract mixin class $CallStateCopyWith<$Res> { factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; @useResult $Res call({ - bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error + bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error }); @@ -68,7 +68,7 @@ class _$CallStateCopyWithImpl<$Res> /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) { return _then(_self.copyWith( isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable @@ -76,7 +76,8 @@ as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCam as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable -as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable +as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, )); } @@ -159,10 +160,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CallState() when $default != null: -return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: +return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _: return orElse(); } @@ -180,10 +181,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error) $default,) {final _that = this; switch (_that) { case _CallState(): -return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);} +return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);} } /// A variant of `when` that fallback to returning `null` /// @@ -197,10 +198,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,) {final _that = this; switch (_that) { case _CallState() when $default != null: -return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: +return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _: return null; } @@ -212,7 +213,7 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable class _CallState with DiagnosticableTreeMixin implements CallState { - const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.error}); + const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.viewMode = ViewMode.grid, this.error}); @override final bool isConnected; @@ -221,6 +222,7 @@ class _CallState with DiagnosticableTreeMixin implements CallState { @override final bool isScreenSharing; @override final bool isSpeakerphone; @override@JsonKey() final Duration duration; +@override@JsonKey() final ViewMode viewMode; @override final String? error; /// Create a copy of CallState @@ -234,21 +236,21 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'CallState')) - ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); + ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error)); } @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error)); } @override -int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); +int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; + return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)'; } @@ -259,7 +261,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; @override @useResult $Res call({ - bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error + bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error }); @@ -276,7 +278,7 @@ class __$CallStateCopyWithImpl<$Res> /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) { return _then(_CallState( isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable @@ -284,7 +286,8 @@ as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCam as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable -as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable +as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, )); } diff --git a/lib/pods/chat/call.g.dart b/lib/pods/chat/call.g.dart index 65e2b0b1..9bc41c17 100644 --- a/lib/pods/chat/call.g.dart +++ b/lib/pods/chat/call.g.dart @@ -41,7 +41,7 @@ final class CallNotifierProvider } } -String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3'; +String _$callNotifierHash() => r'40bd884d3918b8e817329589c921774ab3c62ea2'; abstract class _$CallNotifier extends $Notifier { CallState build(); diff --git a/lib/pods/chat/messages_notifier.g.dart b/lib/pods/chat/messages_notifier.g.dart index 65d08897..a2abf7fa 100644 --- a/lib/pods/chat/messages_notifier.g.dart +++ b/lib/pods/chat/messages_notifier.g.dart @@ -50,7 +50,7 @@ final class MessagesNotifierProvider } } -String _$messagesNotifierHash() => r'284625cff963ff26375d50d7202a33184e810fcb'; +String _$messagesNotifierHash() => r'c7e2cd7f5b8673af88f5076814393dbfbd0d43c5'; final class MessagesNotifierFamily extends $Family with diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index 53b69ce1..0a8a6849 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -69,11 +69,11 @@ class CallScreen extends HookConsumerWidget { callState.isConnected ? formatDuration(callState.duration) : (switch (callNotifier.room?.connectionState) { - ConnectionState.connected => 'connected', - ConnectionState.connecting => 'connecting', - ConnectionState.reconnecting => 'reconnecting', - _ => 'disconnected', - }).tr(), + ConnectionState.connected => 'connected', + ConnectionState.connecting => 'connecting', + ConnectionState.reconnecting => 'reconnecting', + _ => 'disconnected', + }).tr(), style: const TextStyle(fontSize: 14), ), ], @@ -92,40 +92,40 @@ class CallScreen extends HookConsumerWidget { ), ], ), - body: - callState.error != null - ? Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320), - child: Column( - children: [ - const Icon(Symbols.error_outline, size: 48), - const Gap(4), - Text( - callState.error!, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF757575)), - ), - const Gap(8), - TextButton( - onPressed: () { - callNotifier.disconnect(); - callNotifier.dispose(); - callNotifier.joinRoom(room); - }, - child: Text('retry').tr(), - ), - ], - ), + body: callState.error != null + ? Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + children: [ + const Icon(Symbols.error_outline, size: 48), + const Gap(4), + Text( + callState.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF757575)), + ), + const Gap(8), + TextButton( + onPressed: () { + callNotifier.disconnect(); + callNotifier.dispose(); + callNotifier.joinRoom(room); + }, + child: Text('retry').tr(), + ), + ], ), - ) - : Column( - children: [ - Expanded(child: CallContent()), - CallControlsBar(), - Gap(MediaQuery.of(context).padding.bottom + 16), - ], ), + ) + : Column( + children: [ + const SizedBox(width: double.infinity), + Expanded(child: CallContent()), + CallControlsBar(popOnLeaves: true), + Gap(MediaQuery.of(context).padding.bottom + 16), + ], + ), ); } } diff --git a/lib/widgets/chat/call_content.dart b/lib/widgets/chat/call_content.dart index be02db69..69f97684 100644 --- a/lib/widgets/chat/call_content.dart +++ b/lib/widgets/chat/call_content.dart @@ -1,12 +1,89 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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 CallStageView extends HookConsumerWidget { + final List participants; + final double? outerMaxHeight; + + const CallStageView({ + super.key, + required this.participants, + this.outerMaxHeight, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final focusedIndex = useState(0); + + final focusedParticipant = participants[focusedIndex.value]; + final otherParticipants = participants + .where((p) => p != focusedParticipant) + .toList(); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Focused participant (takes most space) + LayoutBuilder( + builder: (context, constraints) { + // Calculate dynamic width based on available space + final maxWidth = constraints.maxWidth * 0.8; + final maxHeight = (outerMaxHeight ?? constraints.maxHeight) * 0.6; + + return Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: CallParticipantTile( + live: focusedParticipant, + allTiles: true, + ), + ), + ); + }, + ), + // Horizontal list of other participants + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final participant in otherParticipants) + Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: 180, + child: GestureDetector( + onTapDown: (_) { + final newIndex = participants.indexOf(participant); + focusedIndex.value = newIndex; + }, + child: CallParticipantTile( + live: participant, + radius: 32, + allTiles: true, + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} class CallContent extends HookConsumerWidget { - const CallContent({super.key}); + final double? outerMaxHeight; + const CallContent({super.key, this.outerMaxHeight}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -34,7 +111,7 @@ class CallContent extends HookConsumerWidget { ); if (allAudioOnly) { - // Audio-only: show avatars in a compact row + // Audio-only: show avatars in a compact row with animated containers return Center( child: SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -45,36 +122,49 @@ class CallContent extends HookConsumerWidget { runSpacing: 8, children: [ for (final live in participants) - SpeakingRippleAvatar( - live: live, - size: 72, - ).padding(horizontal: 4), + Padding( + padding: const EdgeInsets.all(8), + child: SpeakingRippleAvatar(live: live, size: 72), + ), ], ), ), ); } - // Show all participants in a responsive grid - return LayoutBuilder( - builder: (context, constraints) { - // Calculate width for responsive 2-column layout - final itemWidth = (constraints.maxWidth / 2) - 16; + if (callState.viewMode == ViewMode.stage) { + // Stage: allow user to select a participant to focus, show others below + return CallStageView( + participants: participants, + outerMaxHeight: outerMaxHeight, + ); + } else { + // Grid: show all participants in a responsive grid + return LayoutBuilder( + builder: (context, constraints) { + // Calculate width for responsive 2-column layout + final itemWidth = (constraints.maxWidth / 2) - 16; - return Wrap( - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - for (final participant in participants) - SizedBox( - width: itemWidth, - child: CallParticipantTile(live: participant), - ), - ], - ); - }, - ); + return SingleChildScrollView( + child: Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + for (final participant in participants) + SizedBox( + width: itemWidth, + child: CallParticipantTile( + live: participant, + allTiles: true, + ), + ), + ], + ), + ); + }, + ); + } } } diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index ab0a3770..50c27dd2 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -21,7 +21,12 @@ import 'package:livekit_client/livekit_client.dart'; class CallControlsBar extends HookConsumerWidget { final bool isCompact; - const CallControlsBar({super.key, this.isCompact = false}); + final bool popOnLeaves; + const CallControlsBar({ + super.key, + this.isCompact = false, + this.popOnLeaves = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -41,91 +46,97 @@ class CallControlsBar extends HookConsumerWidget { _buildCircularButtonWithDropdown( context: context, ref: ref, - icon: - callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, + icon: callState.isCameraEnabled + ? Symbols.videocam + : Symbols.videocam_off, onPressed: () => callNotifier.toggleCamera(), backgroundColor: const Color(0xFF424242), hasDropdown: true, deviceType: 'videoinput', ), _buildCircularButton( - icon: - callState.isScreenSharing - ? Icons.stop_screen_share - : Icons.screen_share, + icon: callState.isScreenSharing + ? Symbols.stop_screen_share + : Symbols.screen_share, onPressed: () => callNotifier.toggleScreenShare(context), backgroundColor: const Color(0xFF424242), ), _buildCircularButtonWithDropdown( context: context, ref: ref, - icon: callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, + icon: callState.isMicrophoneEnabled ? Symbols.mic : Symbols.mic_off, onPressed: () => callNotifier.toggleMicrophone(), backgroundColor: const Color(0xFF424242), hasDropdown: true, deviceType: 'audioinput', ), _buildCircularButton( - icon: - callState.isSpeakerphone - ? Symbols.mobile_speaker - : Symbols.ear_sound, + icon: callState.isSpeakerphone + ? Symbols.mobile_speaker + : Symbols.ear_sound, onPressed: () => callNotifier.toggleSpeakerphone(), backgroundColor: const Color(0xFF424242), ), + _buildCircularButton( + icon: callState.viewMode == ViewMode.grid + ? Symbols.grid_view + : Symbols.view_list, + onPressed: () => callNotifier.toggleViewMode(), + backgroundColor: const Color(0xFF424242), + ), _buildCircularButton( icon: Icons.call_end, - onPressed: - () => showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: - (innerContext) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(24), - ListTile( - leading: const Icon(Symbols.logout, fill: 1), - title: Text('callLeave').tr(), - onTap: () { - callNotifier.disconnect(); - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - Navigator.of(innerContext).pop(); - }, - ), - ListTile( - leading: const Icon(Symbols.call_end, fill: 1), - iconColor: Colors.red, - title: Text('callEnd').tr(), - onTap: () async { - callNotifier.disconnect(); - final apiClient = ref.watch(apiClientProvider); - try { - showLoadingModal(context); - await apiClient.delete( - '/sphere/chat/realtime/${callNotifier.roomId}', - ); - callNotifier.dispose(); - if (context.mounted) { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - Navigator.of(innerContext).pop(); - } - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - }, - ), - Gap(MediaQuery.of(context).padding.bottom), - ], - ), - ), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (innerContext) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(24), + ListTile( + leading: const Icon(Symbols.logout, fill: 1), + title: Text('callLeave').tr(), + onTap: () { + callNotifier.disconnect(); + if (popOnLeaves) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + Navigator.of(innerContext).pop(); + } + }, + ), + ListTile( + leading: const Icon(Symbols.call_end, fill: 1), + iconColor: Colors.red, + title: Text('callEnd').tr(), + onTap: () async { + callNotifier.disconnect(); + final apiClient = ref.watch(apiClientProvider); + try { + showLoadingModal(context); + await apiClient.delete( + '/sphere/chat/realtime/${callNotifier.roomId}', + ); + callNotifier.dispose(); + if (context.mounted && popOnLeaves) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + Navigator.of(innerContext).pop(); + } + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), + ), backgroundColor: const Color(0xFFE53E3E), iconColor: Colors.white, ), @@ -185,12 +196,11 @@ class CallControlsBar extends HookConsumerWidget { bottom: 0, right: isCompact ? 0 : -4, child: Material( - color: - Colors - .transparent, // Make Material transparent to show underlying color + color: Colors + .transparent, // Make Material transparent to show underlying color child: InkWell( - onTap: - () => _showDeviceSelectionDialog(context, ref, deviceType), + onTap: () => + _showDeviceSelectionDialog(context, ref, deviceType), borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2), child: Container( width: isCompact ? 16 : 24, @@ -232,10 +242,9 @@ class CallControlsBar extends HookConsumerWidget { context: context, builder: (BuildContext dialogContext) { return SheetScaffold( - titleText: - deviceType == 'videoinput' - ? 'selectCamera'.tr() - : 'selectMicrophone'.tr(), + titleText: deviceType == 'videoinput' + ? 'selectCamera'.tr() + : 'selectMicrophone'.tr(), child: ListView.builder( itemCount: devices.length, itemBuilder: (context, index) { @@ -434,30 +443,23 @@ class CallOverlayBar extends HookConsumerWidget { ) { final lastSpeaker = callNotifier.participants - .where( - (element) => element.remoteParticipant.lastSpokeAt != null, - ) - .isEmpty - ? callNotifier.participants.firstOrNull - : callNotifier.participants - .where( - (element) => element.remoteParticipant.lastSpokeAt != null, - ) - .fold( - callNotifier.participants.firstOrNull, - (value, element) => - element.remoteParticipant.lastSpokeAt != null && - (value?.remoteParticipant.lastSpokeAt == null || - element.remoteParticipant.lastSpokeAt! - .compareTo( - value! - .remoteParticipant - .lastSpokeAt!, - ) > - 0) - ? element - : value, - ); + .where((element) => element.remoteParticipant.lastSpokeAt != null) + .isEmpty + ? callNotifier.participants.firstOrNull + : callNotifier.participants + .where((element) => element.remoteParticipant.lastSpokeAt != null) + .fold( + callNotifier.participants.firstOrNull, + (value, element) => + element.remoteParticipant.lastSpokeAt != null && + (value?.remoteParticipant.lastSpokeAt == null || + element.remoteParticipant.lastSpokeAt!.compareTo( + value!.remoteParticipant.lastSpokeAt!, + ) > + 0) + ? element + : value, + ); if (lastSpeaker == null) { return const SizedBox.shrink(key: ValueKey('active_waiting')); @@ -488,16 +490,15 @@ class CallOverlayBar extends HookConsumerWidget { openColor: Theme.of(context).scaffoldBackgroundColor, middleColor: Theme.of(context).scaffoldBackgroundColor, openBuilder: (context, action) => CallScreen(room: room), - closedBuilder: - (context, openContainer) => IconButton( - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - icon: const Icon(Icons.fullscreen), - onPressed: openContainer, - tooltip: 'Full Screen', - ), + closedBuilder: (context, openContainer) => IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + icon: const Icon(Icons.fullscreen), + onPressed: openContainer, + tooltip: 'Full Screen', + ), ), IconButton( visualDensity: const VisualDensity( @@ -512,10 +513,10 @@ class CallOverlayBar extends HookConsumerWidget { ).padding(horizontal: 12, vertical: 8), // Video Preview Container( - height: 200, + height: 320, width: double.infinity, color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const CallContent(), + child: const CallContent(outerMaxHeight: 320), ), const CallControlsBar( isCompact: true, @@ -540,11 +541,10 @@ class CallOverlayBar extends HookConsumerWidget { SizedBox( width: 40, height: 40, - child: - SpeakingRippleAvatar( - live: lastSpeaker, - size: 36, - ).center(), + child: SpeakingRippleAvatar( + live: lastSpeaker, + size: 36, + ).center(), ), const Gap(8), Column( diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart index 161c444a..67310758 100644 --- a/lib/widgets/chat/call_participant_tile.dart +++ b/lib/widgets/chat/call_participant_tile.dart @@ -83,24 +83,21 @@ class SpeakingRippleAvatar extends HookConsumerWidget { alignment: Alignment.center, decoration: const BoxDecoration(shape: BoxShape.circle), child: account.when( - data: - (value) => CallParticipantGestureDetector( - participant: live, - child: ProfilePictureWidget( - file: value.profile.picture, - radius: size / 2, - ), - ), - error: - (_, _) => CircleAvatar( - radius: size / 2, - child: const Icon(Symbols.person_remove), - ), - loading: - () => CircleAvatar( - radius: size / 2, - child: CircularProgressIndicator(), - ), + data: (value) => CallParticipantGestureDetector( + participant: live, + child: ProfilePictureWidget( + file: value.profile.picture, + radius: size / 2, + ), + ), + error: (_, _) => CircleAvatar( + radius: size / 2, + child: const Icon(Symbols.question_mark), + ), + loading: () => CircleAvatar( + radius: size / 2, + child: CircularProgressIndicator(), + ), ), ), if (live.remoteParticipant.isMuted) @@ -130,12 +127,20 @@ class SpeakingRippleAvatar extends HookConsumerWidget { class CallParticipantTile extends HookConsumerWidget { final CallParticipantLive live; + final bool allTiles; + final double radius; - const CallParticipantTile({super.key, required this.live}); + const CallParticipantTile({ + super.key, + required this.live, + this.allTiles = false, + this.radius = 48, + }); @override Widget build(BuildContext context, WidgetRef ref) { final userInfo = ref.watch(accountProvider(live.participant.name)); + final account = ref.watch(accountProvider(live.participant.identity)); final hasVideo = live.hasVideo && @@ -143,7 +148,7 @@ class CallParticipantTile extends HookConsumerWidget { .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) .isNotEmpty; - if (hasVideo) { + if (hasVideo || allTiles) { return Padding( padding: const EdgeInsets.all(8), child: LayoutBuilder( @@ -166,12 +171,11 @@ class CallParticipantTile extends HookConsumerWidget { color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), border: Border.all( - color: - isSpeaking - ? Colors.green.withOpacity( - 0.5 + 0.5 * audioLevel.clamp(0.0, 1.0), - ) - : Theme.of(context).colorScheme.outlineVariant, + color: isSpeaking + ? Colors.green.withOpacity( + 0.5 + 0.5 * audioLevel.clamp(0.0, 1.0), + ) + : Theme.of(context).colorScheme.outlineVariant, width: isSpeaking ? 4 : 1, ), ), @@ -182,14 +186,37 @@ class CallParticipantTile extends HookConsumerWidget { child: Stack( fit: StackFit.expand, children: [ - VideoTrackRenderer( - live.remoteParticipant.trackPublications.values - .where((track) => track.kind == TrackType.VIDEO) - .first - .track - as VideoTrack, - renderMode: VideoRenderMode.platformView, - ), + if (hasVideo) + VideoTrackRenderer( + live.remoteParticipant.trackPublications.values + .where( + (track) => track.kind == TrackType.VIDEO, + ) + .first + .track + as VideoTrack, + renderMode: VideoRenderMode.platformView, + ) + else + Center( + child: account.when( + data: (value) => CallParticipantGestureDetector( + participant: live, + child: ProfilePictureWidget( + file: value.profile.picture, + radius: radius, + ), + ), + error: (_, _) => CircleAvatar( + radius: radius, + child: const Icon(Symbols.question_mark), + ), + loading: () => CircleAvatar( + radius: radius, + child: CircularProgressIndicator(), + ), + ), + ), Positioned( left: 8, bottom: 8, diff --git a/lib/widgets/post/post_shared.g.dart b/lib/widgets/post/post_shared.g.dart index bcf0effe..0e4f290e 100644 --- a/lib/widgets/post/post_shared.g.dart +++ b/lib/widgets/post/post_shared.g.dart @@ -58,7 +58,7 @@ final class RepliesNotifierProvider } } -String _$repliesNotifierHash() => r'2fa51bc3b8cc640e68fa316f61d00f8a0a3740ed'; +String _$repliesNotifierHash() => r'fcaea9b502b1d713a8084da022a03e86d67acc1a'; final class RepliesNotifierFamily extends $Family with