添加设备代码信息到 Android 和 iOS 平台信息;更新 Auth 页面以使用新的 TextEditingController 管理输入;重构推流测试页面以支持动态 RTMP URL 解析;删除不再需要的测试文件。
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
enum AuthApi {
|
||||
/// 获取 token
|
||||
getToken('/api/events/device/token');
|
||||
|
||||
final String path;
|
||||
const AuthApi(this.path);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class AppConfig {
|
||||
current = switch (environment) {
|
||||
AppEnvironment.dev => const EnvironmentValues(
|
||||
environment: AppEnvironment.dev,
|
||||
baseUrl: 'https://example.com/api',
|
||||
baseUrl: 'http://192.168.1.104:8000',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.staging => const EnvironmentValues(
|
||||
|
||||
@@ -5,7 +5,7 @@ class ApiResponse<T> {
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isSuccess => code >= 200 && code < 300;
|
||||
bool get isSuccess => code == 0 || code >= 200 && code < 300;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
|
||||
@@ -28,4 +28,15 @@ class HeaderInterceptor extends Interceptor {
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppDeviceInfo {
|
||||
required this.platform,
|
||||
required this.isPhysicalDevice,
|
||||
required this.values,
|
||||
required this.deviceCode,
|
||||
});
|
||||
|
||||
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
||||
@@ -48,12 +49,14 @@ class AppDeviceInfo {
|
||||
platform: map['platform'] as String? ?? Platform.operatingSystem,
|
||||
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
|
||||
values: values,
|
||||
deviceCode: values['deviceCode'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
final String platform;
|
||||
final bool isPhysicalDevice;
|
||||
final Map<String, String> values;
|
||||
final String deviceCode;
|
||||
}
|
||||
|
||||
class AppPlatformInfo {
|
||||
|
||||
@@ -28,4 +28,12 @@ class DeviceUtils {
|
||||
}
|
||||
return (await AppPlatformInfo.deviceInfo()).values;
|
||||
}
|
||||
|
||||
static Future<String> deviceCode() async {
|
||||
try {
|
||||
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 获取 TOKEN 响应模型
|
||||
class GetTokenResModel {
|
||||
GetTokenResModel({required this.deviceAccessToken});
|
||||
|
||||
final String deviceAccessToken;
|
||||
|
||||
factory GetTokenResModel.fromJson(Map<String, dynamic> json) {
|
||||
return GetTokenResModel(
|
||||
deviceAccessToken: (json['deviceAccessToken'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'deviceAccessToken': deviceAccessToken};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
class AuthPageWidget extends StatefulWidget {
|
||||
class AuthPageWidget extends ConsumerStatefulWidget {
|
||||
const AuthPageWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AuthPageWidget> createState() => _AuthPageWidgetState();
|
||||
ConsumerState<AuthPageWidget> createState() => _AuthPageWidgetState();
|
||||
}
|
||||
|
||||
class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
late TextEditingController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -26,7 +44,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
SizedBox(
|
||||
width: 280.w,
|
||||
height: 80.h,
|
||||
child: AppTextField(initialValue: 'hhh'),
|
||||
child: AppTextField(initialValue: 'hhh', controller: _controller),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(
|
||||
@@ -34,8 +52,20 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
height: 80.h,
|
||||
child: AppButton(
|
||||
label: '确定',
|
||||
onPressed: () {
|
||||
AppNavigator.push(const ScanQrCodePage());
|
||||
onPressed: () async {
|
||||
final code = _controller?.text;
|
||||
final success = await ref
|
||||
.read(authProvider.notifier)
|
||||
.auth(code ?? '');
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
AppNavigator.push(const ScanQrCodePage());
|
||||
return;
|
||||
}
|
||||
final message = ref.read(authProvider).errorMessage;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
AppToast.show(message);
|
||||
}
|
||||
// return;
|
||||
// AppNavigator.push(
|
||||
// const WebviewPage(
|
||||
@@ -45,6 +75,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
// );
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
isLoading: authState.isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:recording_tool/app/config/api_common.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/network/providers/dio_providers.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||
import 'package:recording_tool/features/auth/model/model_auth.dart';
|
||||
|
||||
class AuthServer {
|
||||
/// [passCode] 口令码
|
||||
static Future<GetTokenResModel> login(String passCode, Ref ref) async {
|
||||
final deviceCode = await DeviceUtils.deviceCode();
|
||||
if (deviceCode.isEmpty) {
|
||||
throw const FormatException('无法获取设备标识');
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final data = await apiClient.post<GetTokenResModel>(
|
||||
AuthApi.getToken.path,
|
||||
data: {'deviceCode': deviceCode, 'passCode': passCode},
|
||||
parser: (json) => GetTokenResModel.fromJson(json as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
if (data.deviceAccessToken.isEmpty) {
|
||||
throw const FormatException('登录响应缺少 TOKEN');
|
||||
}
|
||||
|
||||
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class AuthState {
|
||||
const AuthState({this.isLoading = false, this.errorMessage});
|
||||
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
AuthState copyWith({bool? isLoading, String? errorMessage}) {
|
||||
return AuthState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
||||
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthViewModel, AuthState>((ref) {
|
||||
return AuthViewModel(ref);
|
||||
});
|
||||
|
||||
class AuthViewModel extends StateNotifier<AuthState> {
|
||||
AuthViewModel(this._ref) : super(const AuthState());
|
||||
final Ref _ref;
|
||||
|
||||
/// 鉴权获取 token
|
||||
Future<bool> auth(String code) async {
|
||||
final passCode = code.trim();
|
||||
if (passCode.isEmpty) {
|
||||
state = const AuthState(errorMessage: '请输入执裁口令');
|
||||
return false;
|
||||
}
|
||||
|
||||
state = const AuthState(isLoading: true);
|
||||
try {
|
||||
await AuthServer.login(passCode, _ref);
|
||||
state = const AuthState();
|
||||
return true;
|
||||
} on FormatException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} on ApiException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} catch (_) {
|
||||
state = const AuthState(errorMessage: '认证失败,请重试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class PushSteamTestWidget extends StatefulWidget {
|
||||
const PushSteamTestWidget({super.key});
|
||||
final String steamKey = '20260708';
|
||||
final String rtmpUrl = 'rtmp://192.168.1.180:19090/live/';
|
||||
const PushSteamTestWidget({
|
||||
super.key,
|
||||
this.rtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
|
||||
});
|
||||
|
||||
final String rtmpUrl;
|
||||
|
||||
@override
|
||||
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
|
||||
@@ -57,19 +64,24 @@ class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
|
||||
}
|
||||
}
|
||||
|
||||
// 把服务端下发的完整 rtmp:// 地址拆成 "服务器地址" + "streamKey" 两部分
|
||||
Future<void> _startPush() async {
|
||||
// final uri = Uri.parse(widget.rtmpUrl);
|
||||
final streamKey = widget.steamKey;
|
||||
// final streamKey =
|
||||
// '${DateTime.timestamp()}-${Random(1000).nextInt(1000).toString()}';
|
||||
|
||||
// debugPrint('streamKey- $streamKey ');
|
||||
// final baseUrl = widget.rtmpUrl.substring(
|
||||
// 0,
|
||||
// widget.rtmpUrl.lastIndexOf(streamKey),
|
||||
// );
|
||||
await _controller.startStreaming(streamKey: streamKey, url: widget.rtmpUrl);
|
||||
try {
|
||||
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
|
||||
await _controller.startStreaming(
|
||||
streamKey: target.streamKey,
|
||||
url: target.url,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
debugPrint('推流地址错误: ${e.message}');
|
||||
AppToast.show(e.message);
|
||||
} on PlatformException catch (e) {
|
||||
final message = e.message ?? e.code;
|
||||
debugPrint('推流失败: ${e.code} $message');
|
||||
AppToast.show('推流失败: $message');
|
||||
} catch (e) {
|
||||
debugPrint('推流失败: $e');
|
||||
AppToast.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopPush() => _controller.stopStreaming();
|
||||
|
||||
@@ -40,7 +40,7 @@ class _AuthPageWidgetState extends State<ScanQrCodePage> {
|
||||
if (result == null || result.isEmpty) return;
|
||||
debugPrint('扫码结果: $result');
|
||||
Future.delayed(const Duration(milliseconds: 1)).then((_) {
|
||||
AppNavigator.push(PushSteamTestWidget());
|
||||
AppNavigator.push(PushSteamTestWidget(rtmpUrl: result));
|
||||
// AppNavigator.push(
|
||||
// const WebviewPage(url: 'https://www.dronex.cc/'),
|
||||
// );
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class RtmpStreamTarget {
|
||||
const RtmpStreamTarget({required this.url, required this.streamKey});
|
||||
|
||||
final String url;
|
||||
final String streamKey;
|
||||
|
||||
factory RtmpStreamTarget.parse(String value) {
|
||||
final source = value.trim();
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null ||
|
||||
(uri.scheme != 'rtmp' && uri.scheme != 'rtmps') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('推流地址必须是 rtmp:// 或 rtmps:// 开头的完整地址');
|
||||
}
|
||||
|
||||
final pathSegments = uri.pathSegments
|
||||
.where((segment) => segment.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (pathSegments.length < 2) {
|
||||
throw const FormatException('推流地址路径必须包含 app 和 streamKey,例如 /app/xxxx');
|
||||
}
|
||||
|
||||
final appPath = pathSegments.take(pathSegments.length - 1).join('/');
|
||||
final streamKey = pathSegments.last;
|
||||
final baseUrl = uri
|
||||
.replace(path: '/$appPath', query: null, fragment: null)
|
||||
.toString();
|
||||
|
||||
return RtmpStreamTarget(url: baseUrl, streamKey: streamKey);
|
||||
}
|
||||
|
||||
String get fullUrl => '$url/$streamKey';
|
||||
}
|
||||
Reference in New Issue
Block a user