feat: WebView/媒体/红包详情全量实现 (#25~#30)

#25 MiniAppWebViewPage + MiniAppRouter
  - webview_flutter 加载 {apiBaseUrl}/miniapp/{appId}/index.html?gameId=...&token=...
  - MiniAppFloatButton 接收 chatId/chatType,默认打开 WebView
  - BannerState 新增 appId 字段,由 GameBannerData.appId 填充

#26 open_filex 文件打开
  - FileMessageBubble 下载完成后调用 OpenFilex.open(localPath)
  - 打开失败时 SnackBar 提示

#27 audioplayers 音频播放
  - AudioPlaybackService(Notifier):单例 AudioPlayer,togglePlay/pause/seek
  - AudioMessageBubble 接入:播放态图标切换、进度 mm:ss 显示

#28 video_player + chewie 视频全屏
  - VideoPlayerPage:本地文件 / HTTP 双模,chewie 控制栏
  - VideoMessageBubble 默认 onTap → push VideoPlayerPage

#29 红包领取排行榜详情页
  - GET /payment/rp/detail → RpDetailData + RpRecordItem DTO
  - GetRpDetailUseCase + getRpDetailUseCaseProvider
  - RedEnvelopeDetailSheet:汇总行 + 领取排行列表,头像/昵称/金额/时间

#30 MINE_RP 地雷红包发包 UI
  - _RpType 新增 mine(MINE_RP),显示地雷金额输入框
  - SendRpRequest.parameters 携带 mineAmount
  - RedEnvelopeBubble:非活跃状态直接打开详情,活跃状态领取后打开排行榜

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 12:53:55 +09:00
parent e715a0673b
commit 23fc6b0c86
17 changed files with 1068 additions and 59 deletions

View File

@@ -0,0 +1,117 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 音频播放状态
///
/// 对应 Gitea issue #31
class AudioPlaybackState {
/// 当前加载的本地文件路径null = 空闲)
final String? currentUrl;
final bool isPlaying;
final Duration position;
final Duration totalDuration;
const AudioPlaybackState({
this.currentUrl,
this.isPlaying = false,
this.position = Duration.zero,
this.totalDuration = Duration.zero,
});
bool isPlayingUrl(String url) => currentUrl == url && isPlaying;
bool isLoadedUrl(String url) => currentUrl == url;
}
/// 音频播放服务(全局单例,每次只允许一条音频播放)
///
/// ## 使用
///
/// ```dart
/// // AudioMessageBubble
/// final state = ref.watch(audioPlaybackServiceProvider);
/// final isPlaying = state.isPlayingUrl(localPath);
///
/// // 点击播放/暂停
/// ref.read(audioPlaybackServiceProvider.notifier).togglePlay(localPath);
/// ```
class AudioPlaybackService extends Notifier<AudioPlaybackState> {
late final AudioPlayer _player;
@override
AudioPlaybackState build() {
_player = AudioPlayer();
_player.onPlayerStateChanged.listen((playerState) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: playerState == PlayerState.playing,
position: state.position,
totalDuration: state.totalDuration,
);
});
_player.onPositionChanged.listen((pos) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: state.isPlaying,
position: pos,
totalDuration: state.totalDuration,
);
});
_player.onDurationChanged.listen((dur) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: state.isPlaying,
position: state.position,
totalDuration: dur,
);
});
_player.onPlayerComplete.listen((_) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: false,
position: Duration.zero,
totalDuration: state.totalDuration,
);
});
ref.onDispose(_player.dispose);
return const AudioPlaybackState();
}
/// 播放/暂停 [localPath] 对应的音频文件
///
/// - 若当前已在播放同一文件:暂停
/// - 若当前已暂停同一文件:继续
/// - 否则:停止旧文件,播放新文件
Future<void> togglePlay(String localPath) async {
if (state.currentUrl == localPath && state.isPlaying) {
await _player.pause();
} else if (state.currentUrl == localPath && !state.isPlaying) {
await _player.resume();
} else {
await _player.stop();
state = const AudioPlaybackState();
state = AudioPlaybackState(currentUrl: localPath);
await _player.play(DeviceFileSource(localPath));
}
}
/// 停止播放并重置状态
Future<void> stop() async {
await _player.stop();
state = const AudioPlaybackState();
}
/// 跳转到指定位置
Future<void> seek(Duration position) async {
await _player.seek(position);
}
}
final audioPlaybackServiceProvider =
NotifierProvider<AudioPlaybackService, AudioPlaybackState>(
AudioPlaybackService.new,
);