yumi-flutter/lib/ui_kit/widgets/room/room_play_widget.dart
2026-05-23 12:59:03 +08:00

693 lines
22 KiB
Dart

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<RoomPlayWidget> createState() => _RoomPlayWidgetState();
}
class _RoomPlayWidgetState extends State<RoomPlayWidget> {
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<RoomMusicManager, bool>(
(manager) => manager.current != null,
);
final hasRocket = context.select<RtcProvider, bool>(
(provider) => _hasRoomRocketEntry(provider),
);
final hasRoomBanner = context.select<SCAppGeneralManager, bool>(
(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 = <Widget>[];
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<bool>? 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<String> _verifiedKnownPacketIds = const <String>{};
Set<String> _verifiedVisiblePacketIds = const <String>{};
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<RtcProvider>(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 <String>{};
_verifiedVisiblePacketIds = const <String>{};
_refreshRoomRedPacketList();
} catch (_) {}
}
void _refreshRoomRedPacketList() {
if (_refreshingList || !mounted) {
return;
}
try {
final rtcProvider = Provider.of<RtcProvider>(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<RtcProvider, RtmProvider>(
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<RtcProvider>(context, listen: false),
rtmProvider: Provider.of<RtmProvider>(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<String> verifiedKnownPacketIds,
required Set<String> verifiedVisiblePacketIds,
}) {
final packets = <RoomRedPacketUiData>[];
final hiddenPacketIds = <String>{};
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<RoomRedPacketUiData> 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<RoomRedPacketUiData> packets,
RoomRedPacketUiData packet, {
Set<String>? hiddenPacketIds,
Set<String>? verifiedKnownPacketIds,
Set<String>? 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;
}