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:
@@ -36,6 +36,7 @@ class ApiPaths {
|
||||
static const rpSend = '/payment/rp/send';
|
||||
static const rpReceive = '/app/api/wallet/rp/receive';
|
||||
static const rpConfigGet = '/payment/rp-config/get';
|
||||
static const rpDetail = '/payment/rp/detail';
|
||||
|
||||
// ── Game Banner ──
|
||||
static const bannerGet = '/lucky/banner/get';
|
||||
|
||||
117
apps/im_app/lib/core/services/audio_playback_service.dart
Normal file
117
apps/im_app/lib/core/services/audio_playback_service.dart
Normal 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,
|
||||
);
|
||||
Reference in New Issue
Block a user