import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart'; import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart'; import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart'; class RoomPlayWidget extends StatefulWidget { const RoomPlayWidget({super.key}); @override State createState() => _RoomPlayWidgetState(); } class _RoomPlayWidgetState extends State { static const double _railEnd = 8; static const double _railBottom = 30; static const double _railWidth = 58; static const double _railVerticalPadding = 8; static const double _actionExtent = 56; static const double _actionGap = 5; static const double _toggleExtent = 20; static const int _compactActionCount = 2; bool _rightActionsExpanded = false; bool _redPacketEntryVisible = false; void _handleRedPacketEntryVisibilityChanged(bool visible) { if (_redPacketEntryVisible == visible || !mounted) { return; } setState(() { _redPacketEntryVisible = visible; }); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final hasCurrentMusic = context.select( (manager) => manager.current != null, ); final hasRocket = context.select( (provider) => _hasRoomRocketEntry(provider), ); final hasRoomBanner = context.select( (manager) => manager.roomBanners.isNotEmpty, ); final actions = <_RoomSideAction>[ if (_redPacketEntryVisible) _RoomSideAction.redPacket, if (hasRocket) _RoomSideAction.rocket, if (hasCurrentMusic) _RoomSideAction.music, _RoomSideAction.game, if (hasRoomBanner) _RoomSideAction.banner, ]; final fullHeight = _railHeight( actionCount: actions.length, showToggle: false, showBackground: false, ); final availableHeight = (constraints.maxHeight - _railBottom.w) .clamp(0.0, constraints.maxHeight) .toDouble(); final shouldFold = actions.length > _compactActionCount && availableHeight < fullHeight; final isExpanded = !shouldFold || _rightActionsExpanded; final isCollapsed = shouldFold && !isExpanded; final visibleActions = isExpanded ? actions : actions.take(_compactActionCount).toList(); final railHeight = _railHeight( actionCount: visibleActions.length, showToggle: shouldFold, showBackground: isCollapsed, ); Widget rail = _RoomSideActionRail( actions: visibleActions, showToggle: shouldFold, expanded: isExpanded, showBackground: isCollapsed, onToggle: () { setState(() { _rightActionsExpanded = !_rightActionsExpanded; }); }, actionBuilder: _buildAction, ); if (shouldFold && isExpanded && railHeight > availableHeight) { rail = SizedBox( width: _railWidth.w, height: availableHeight.clamp(0.0, railHeight).toDouble(), child: FittedBox( alignment: AlignmentDirectional.bottomEnd, fit: BoxFit.scaleDown, child: rail, ), ); } return SizedBox.expand( child: Stack( clipBehavior: Clip.none, children: [ PositionedDirectional( end: _railEnd.w, bottom: _railBottom.w, child: rail, ), if (!_redPacketEntryVisible) PositionedDirectional( end: 0, bottom: 0, child: Offstage( child: _RoomRedPacketFloatingEntry( onVisibilityChanged: _handleRedPacketEntryVisibilityChanged, ), ), ), ], ), ); }, ); } bool _hasRoomRocketEntry(RtcProvider provider) { final status = provider.roomRocketStatus; if (status == null || !status.isEnabled) { return false; } return status.levels?.any((item) => item.rocketIconUrl.trim().isNotEmpty) ?? false; } double _railHeight({ required int actionCount, required bool showToggle, required bool showBackground, }) { final actionGaps = actionCount > 1 ? actionCount - 1 : 0; final toggleHeight = showToggle ? _toggleExtent + (actionCount > 0 ? _actionGap : 0) : 0.0; final verticalPadding = showBackground ? _railVerticalPadding * 2 : 0.0; return (verticalPadding + toggleHeight + actionCount * _actionExtent + actionGaps * _actionGap) .w; } Widget _buildAction(_RoomSideAction action) { final child = switch (action) { _RoomSideAction.redPacket => _RoomRedPacketFloatingEntry( onVisibilityChanged: _handleRedPacketEntryVisibilityChanged, ), _RoomSideAction.rocket => const RoomRocketFloatingEntry(), _RoomSideAction.music => const RoomMusicFloatingEntry(), _RoomSideAction.game => const RoomGameEntryButton(), _RoomSideAction.banner => RoomBannerView(), }; return SizedBox( width: 46.w, height: _actionExtent.w, child: Center(child: child), ); } } enum _RoomSideAction { redPacket, rocket, music, game, banner } class _RoomSideActionRail extends StatelessWidget { const _RoomSideActionRail({ required this.actions, required this.showToggle, required this.expanded, required this.showBackground, required this.onToggle, required this.actionBuilder, }); final List<_RoomSideAction> actions; final bool showToggle; final bool expanded; final bool showBackground; final VoidCallback onToggle; final Widget Function(_RoomSideAction action) actionBuilder; @override Widget build(BuildContext context) { final children = []; if (showToggle) { children.add( SCDebounceWidget( onTap: onToggle, child: SizedBox( width: 46.w, height: _RoomPlayWidgetState._toggleExtent.w, child: Center(child: _RoomSideToggleIcon(expanded: expanded)), ), ), ); if (actions.isNotEmpty) { children.add(SizedBox(height: _RoomPlayWidgetState._actionGap.w)); } } for (var index = 0; index < actions.length; index++) { if (index > 0) { children.add(SizedBox(height: _RoomPlayWidgetState._actionGap.w)); } children.add( KeyedSubtree( key: ValueKey(actions[index]), child: actionBuilder(actions[index]), ), ); } return Container( width: _RoomPlayWidgetState._railWidth.w, padding: EdgeInsets.symmetric( horizontal: 6.w, vertical: showBackground ? _RoomPlayWidgetState._railVerticalPadding.w : 0, ), decoration: showBackground ? BoxDecoration( color: Colors.black.withValues(alpha: 0.21), borderRadius: BorderRadius.circular(8.w), ) : null, child: Column(mainAxisSize: MainAxisSize.min, children: children), ); } } class _RoomSideToggleIcon extends StatelessWidget { const _RoomSideToggleIcon({required this.expanded}); final bool expanded; @override Widget build(BuildContext context) { return Transform.rotate( angle: expanded ? 3.141592653589793 : 0, child: CustomPaint( size: Size(16.w, 16.w), painter: const _RoomSideTogglePainter(), ), ); } } class _RoomSideTogglePainter extends CustomPainter { const _RoomSideTogglePainter(); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke ..strokeWidth = 2 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final width = size.width; final left = width * 0.22; final center = width * 0.5; final right = width * 0.78; void drawChevron(double top, double bottom) { final path = Path() ..moveTo(left, top) ..lineTo(center, bottom) ..lineTo(right, top); canvas.drawPath(path, paint); } drawChevron(size.height * 0.30, size.height * 0.52); drawChevron(size.height * 0.52, size.height * 0.74); } @override bool shouldRepaint(covariant _RoomSideTogglePainter oldDelegate) => false; } class _RoomRedPacketFloatingEntry extends StatefulWidget { const _RoomRedPacketFloatingEntry({this.onVisibilityChanged}); final ValueChanged? onVisibilityChanged; @override State<_RoomRedPacketFloatingEntry> createState() => _RoomRedPacketFloatingEntryState(); } class _RoomRedPacketFloatingEntryState extends State<_RoomRedPacketFloatingEntry> { static const int _listRefreshIntervalSeconds = 5; Timer? _entryRefreshTimer; bool _refreshingList = false; int _elapsedSeconds = 0; String _observedRoomId = ''; String _verifiedRoomId = ''; int _verifiedRoomRefreshCount = 0; Set _verifiedKnownPacketIds = const {}; Set _verifiedVisiblePacketIds = const {}; bool? _lastNotifiedVisibility; @override void initState() { super.initState(); _entryRefreshTimer = Timer.periodic( const Duration(seconds: 1), (_) => _onEntryTimerTick(), ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _refreshRoomRedPacketList(); } }); } @override void dispose() { _entryRefreshTimer?.cancel(); super.dispose(); } void _notifyVisibilityChanged(bool visible) { if (_lastNotifiedVisibility == visible) { return; } _lastNotifiedVisibility = visible; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } widget.onVisibilityChanged?.call(visible); }); } void _onEntryTimerTick() { RoomRedPacketPendingCache.instance.pruneExpired(); if (!mounted) { return; } _refreshRoomRedPacketListIfRoomChanged(); setState(() {}); _elapsedSeconds++; if (_elapsedSeconds % _listRefreshIntervalSeconds == 0) { _refreshRoomRedPacketList(); } } void _refreshRoomRedPacketListIfRoomChanged() { try { final rtcProvider = Provider.of(context, listen: false); final currentRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; if (currentRoomId.isEmpty || currentRoomId == _observedRoomId) { return; } _observedRoomId = currentRoomId; _elapsedSeconds = 0; _verifiedRoomId = ''; _verifiedRoomRefreshCount = 0; _verifiedKnownPacketIds = const {}; _verifiedVisiblePacketIds = const {}; _refreshRoomRedPacketList(); } catch (_) {} } void _refreshRoomRedPacketList() { if (_refreshingList || !mounted) { return; } try { final rtcProvider = Provider.of(context, listen: false); final currentRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; if (currentRoomId.isEmpty) { return; } _refreshingList = true; unawaited( rtcProvider .loadRoomRedPacketList(1) .then((result) { final activeRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?.trim() ?? ''; if (activeRoomId != currentRoomId) { return result; } final serverPackets = result.map(RoomRedPacketUiData.fromListRes).toList(); final serverPacketIds = serverPackets .map((item) => item.packetId.trim()) .where((packetId) => packetId.isNotEmpty) .toSet(); final visiblePacketIds = serverPackets .map(RoomRedPacketOpenDialog.withLocalClaimState) .where(_shouldShowRoomRedPacketEntry) .map((item) => item.packetId.trim()) .where((packetId) => packetId.isNotEmpty) .toSet(); if (!mounted) { return result; } setState(() { if (_verifiedRoomId == currentRoomId) { _verifiedRoomRefreshCount++; _verifiedKnownPacketIds = { ..._verifiedKnownPacketIds, ...serverPacketIds, }; } else { _verifiedRoomId = currentRoomId; _verifiedRoomRefreshCount = 1; _verifiedKnownPacketIds = serverPacketIds; } _verifiedVisiblePacketIds = visiblePacketIds; }); return result; }) .catchError((_) => rtcProvider.redPacketList) .whenComplete(() { _refreshingList = false; }), ); } catch (_) { _refreshingList = false; } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: Listenable.merge([ RoomRedPacketPendingCache.instance, RoomRedPacketOpenDialog.claimedStateNotifier, ]), builder: (context, child) { return Consumer2( builder: (context, rtcProvider, rtmProvider, child) { final snapshot = _RoomRedPacketEntrySnapshot.fromProviders( rtcProvider: rtcProvider, rtmProvider: rtmProvider, verifiedRoomId: _verifiedRoomId, verifiedRoomRefreshCount: _verifiedRoomRefreshCount, verifiedKnownPacketIds: _verifiedKnownPacketIds, verifiedVisiblePacketIds: _verifiedVisiblePacketIds, ); _notifyVisibilityChanged(snapshot.packets.isNotEmpty); if (snapshot.packets.isEmpty) { return const SizedBox.shrink(); } return SCDebounceWidget( onTap: () { final freshSnapshot = _RoomRedPacketEntrySnapshot.fromProviders( rtcProvider: Provider.of(context, listen: false), rtmProvider: Provider.of(context, listen: false), verifiedRoomId: _verifiedRoomId, verifiedRoomRefreshCount: _verifiedRoomRefreshCount, verifiedKnownPacketIds: _verifiedKnownPacketIds, verifiedVisiblePacketIds: _verifiedVisiblePacketIds, ); if (freshSnapshot.packets.isEmpty) { return; } RoomRedPacketOpenDialog.showList( context, freshSnapshot.packets, ); }, child: SizedBox( width: 46.w, height: 46.w, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ Image.asset( 'sc_images/room/red_packet/small_packet_icon.png', width: 44.w, height: 44.w, fit: BoxFit.contain, gaplessPlayback: true, ), if (snapshot.packets.length > 1) PositionedDirectional( top: -2.w, end: -2.w, child: Container( constraints: BoxConstraints(minWidth: 16.w), height: 16.w, padding: EdgeInsets.symmetric(horizontal: 4.w), alignment: Alignment.center, decoration: BoxDecoration( color: const Color(0xFFFF3D34), borderRadius: BorderRadius.circular(8.w), border: Border.all(color: Colors.white, width: 1.w), ), child: Text( snapshot.packets.length > 99 ? '99+' : snapshot.packets.length.toString(), textScaler: TextScaler.noScaling, maxLines: 1, style: TextStyle( color: Colors.white, fontSize: 9.sp, fontWeight: FontWeight.w800, decoration: TextDecoration.none, ), ), ), ), ], ), ), ); }, ); }, ); } } class _RoomRedPacketEntrySnapshot { const _RoomRedPacketEntrySnapshot(this.packets, this.signature); factory _RoomRedPacketEntrySnapshot.fromProviders({ required RtcProvider rtcProvider, required RtmProvider rtmProvider, required String verifiedRoomId, required int verifiedRoomRefreshCount, required Set verifiedKnownPacketIds, required Set verifiedVisiblePacketIds, }) { final packets = []; final hiddenPacketIds = {}; final currentRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; final shouldTrustVerifiedAbsence = currentRoomId.isNotEmpty && verifiedRoomId == currentRoomId && verifiedRoomRefreshCount >= 2; for (final packet in rtcProvider.redPacketList.map( RoomRedPacketUiData.fromListRes, )) { _addVisiblePacket( packets, packet, hiddenPacketIds: hiddenPacketIds, authoritative: true, ); } for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom( currentRoomId, )) { _addVisiblePacket( packets, packet, hiddenPacketIds: hiddenPacketIds, verifiedKnownPacketIds: shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null, verifiedVisiblePacketIds: shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null, ); } for (final msg in rtmProvider.roomChatMsgList) { if (msg.type != SCRoomMsgType.roomRedPacket) { continue; } _addVisiblePacket( packets, RoomRedPacketUiData.fromMessagePayload( payload: msg.msg, fallbackUser: msg.user, ), hiddenPacketIds: hiddenPacketIds, verifiedKnownPacketIds: shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null, verifiedVisiblePacketIds: shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null, ); } final signature = packets .map( (item) => '${item.packetId}|${item.status}|${item.remainCount}|${item.grabbed}|${item.claimedAmount}|${item.claimStartTimeMs}|${item.expireTimeMs}', ) .join(';'); return _RoomRedPacketEntrySnapshot(packets, signature); } final List packets; final String signature; @override bool operator ==(Object other) { if (identical(this, other)) { return true; } return other is _RoomRedPacketEntrySnapshot && other.signature == signature; } @override int get hashCode => signature.hashCode; } void _addVisiblePacket( List packets, RoomRedPacketUiData packet, { Set? hiddenPacketIds, Set? verifiedKnownPacketIds, Set? verifiedVisiblePacketIds, bool authoritative = false, }) { final normalizedPacket = RoomRedPacketOpenDialog.withLocalClaimState(packet); final packetId = normalizedPacket.packetId.trim(); if (packetId.isNotEmpty && RoomRedPacketOpenDialog.isClaimedLocally(packetId)) { if (authoritative) { hiddenPacketIds?.add(packetId); } return; } if (packetId.isNotEmpty && RoomRedPacketOpenDialog.isUnavailableLocally(packetId)) { if (authoritative) { hiddenPacketIds?.add(packetId); } return; } if (!authoritative && packetId.isNotEmpty && (hiddenPacketIds?.contains(packetId) ?? false)) { return; } if (!authoritative && packetId.isNotEmpty && verifiedKnownPacketIds != null && verifiedVisiblePacketIds != null && verifiedKnownPacketIds.contains(packetId) && !verifiedVisiblePacketIds.contains(packetId)) { return; } if (!_shouldShowRoomRedPacketEntry(normalizedPacket)) { if (authoritative && packetId.isNotEmpty) { hiddenPacketIds?.add(packetId); } return; } if (packetId.isNotEmpty && packets.any((item) => item.packetId.trim() == packetId)) { final existingIndex = packets.indexWhere( (item) => item.packetId.trim() == packetId, ); packets[existingIndex] = packets[existingIndex].withFallbackProfile( normalizedPacket, ); return; } packets.add(normalizedPacket); } bool _shouldShowRoomRedPacketEntry(RoomRedPacketUiData item) { return !item.grabbed && !item.isExpired && !item.isSoldOut; }