Merge branch 'main' of ssh://94.183.170.121:222/wikm/watch
This commit is contained in:
commit
bedecb82bd
|
|
@ -1,13 +1,20 @@
|
|||
allprojects {
|
||||
repositories {
|
||||
maven { url = uri("https://archive.ito.gov.ir/gradle/maven_central") }
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/google")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/central")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/public")
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
|
|
@ -15,20 +22,7 @@ subprojects {
|
|||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
val setupNamespace = Action<Project> {
|
||||
if (hasProperty("android")) {
|
||||
val android = property("android") as? com.android.build.gradle.BaseExtension
|
||||
if (android?.namespace == null) {
|
||||
android?.namespace = "com.github.twyatt.${name.replace("-", ".")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.executed) {
|
||||
setupNamespace.execute(this)
|
||||
} else {
|
||||
afterEvaluate(setupNamespace)
|
||||
}
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +1,12 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
org.gradle.caching=true
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
|
||||
systemProp.http.proxyHost=127.0.0.1
|
||||
systemProp.http.proxyPort=10809
|
||||
systemProp.https.proxyHost=127.0.0.1
|
||||
systemProp.https.proxyPort=10809
|
||||
systemProp.http.nonProxyHosts=localhost|127.*|[::1]
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
|
|
@ -11,14 +10,28 @@ pluginManagement {
|
|||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
maven { url = uri("https://archive.ito.gov.ir/gradle/maven_central") }
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/google")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/central")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/public")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/gradle-plugin")
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ class AppConfig {
|
|||
// ── Debug mode ──────────────────────────────────────────────────────────
|
||||
// true → از دادههای فیک استفاده میکند (بدون نیاز به سرور)
|
||||
// false → به سرور واقعی وصل میشود
|
||||
static const bool debug = true;
|
||||
static const bool debug = false;
|
||||
|
||||
// ── Server ───────────────────────────────────────────────────────────────
|
||||
static const String serverHost = '192.168.1.100';
|
||||
static const String serverHost = '10.225.63.122';
|
||||
static const int serverPort = 8000;
|
||||
static const int livekitPort = 7880;
|
||||
static const bool useSecure = false;
|
||||
|
||||
static String get baseUrl =>
|
||||
|
|
@ -15,4 +16,8 @@ class AppConfig {
|
|||
|
||||
static String get wsBaseUrl =>
|
||||
'${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
|
||||
|
||||
// آدرس سرور LiveKit (پورت جداگانه از بکند)
|
||||
static String get livekitUrl =>
|
||||
'${useSecure ? 'wss' : 'ws'}://$serverHost:$livekitPort';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,139 +63,6 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _showPrivateJoin() async {
|
||||
final keyCtrl = TextEditingController();
|
||||
bool joining = false;
|
||||
String? joinError;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(builder: (ctx, setDialog) {
|
||||
return Dialog(
|
||||
backgroundColor: const Color(0xFF1C1C1E),
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_outline,
|
||||
color: Color(0xFFFFAB00), size: 22),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'کانال خصوصی',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: keyCtrl,
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'کلید ورود',
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.white38, fontSize: 11),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2C2C2E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (joinError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(joinError!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFF1744), fontSize: 10)),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
child: const Text('انصراف',
|
||||
style: TextStyle(
|
||||
color: Colors.white54, fontSize: 11)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: joining
|
||||
? null
|
||||
: () async {
|
||||
final key = keyCtrl.text.trim();
|
||||
if (key.isEmpty) {
|
||||
setDialog(
|
||||
() => joinError = 'کلید را وارد کنید');
|
||||
return;
|
||||
}
|
||||
setDialog(() {
|
||||
joining = true;
|
||||
joinError = null;
|
||||
});
|
||||
final ch =
|
||||
await _api.joinPrivateChannel(key);
|
||||
if (!ctx.mounted) return;
|
||||
if (ch != null) {
|
||||
Navigator.pop(ctx);
|
||||
_enterChannel(ch);
|
||||
} else {
|
||||
setDialog(() {
|
||||
joining = false;
|
||||
joinError = 'کلید نادرست';
|
||||
});
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFFAB00),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: joining
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.black, strokeWidth: 2),
|
||||
)
|
||||
: const Text('ورود',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
keyCtrl.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -275,13 +142,8 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
|
|||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
itemCount: _channels.length + 1,
|
||||
itemCount: _channels.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == _channels.length) {
|
||||
// Private join button
|
||||
return _PrivateJoinTile(
|
||||
onTap: _showPrivateJoin);
|
||||
}
|
||||
return _ChannelTile(
|
||||
channel: _channels[i],
|
||||
onTap: () => _enterChannel(_channels[i]),
|
||||
|
|
@ -328,15 +190,6 @@ class _ChannelTile extends StatelessWidget {
|
|||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (channel.memberCount > 0) ...[
|
||||
Text(
|
||||
'${channel.memberCount}',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white38, size: 11),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
||||
],
|
||||
|
|
@ -345,46 +198,3 @@ class _ChannelTile extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivateJoinTile extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PrivateJoinTile({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2400),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFFAB00).withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.lock_open_outlined,
|
||||
color: Color(0xFFFFAB00), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'ورود به کانال خصوصی',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFFAB00),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Color(0xFFFFAB00), size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/ptt_service.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class ChannelScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
|
@ -16,7 +21,6 @@ class ChannelScreen extends StatefulWidget {
|
|||
class _ChannelScreenState extends State<ChannelScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final PttService _ptt;
|
||||
late final ApiService _api;
|
||||
|
||||
PttState _state = PttState.idle;
|
||||
String? _speaker;
|
||||
|
|
@ -25,20 +29,30 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _pulseAnim;
|
||||
|
||||
// ── Wrist-flip detection (gyroscope) ────────────────────────────────
|
||||
static const double _gyroThreshold = 9.0; // rad/s
|
||||
static const Duration _flipWindow = Duration(milliseconds: 900);
|
||||
static const Duration _flipCooldown = Duration(milliseconds: 380);
|
||||
|
||||
bool _wristEnabled = false;
|
||||
bool _wristAvailable = false;
|
||||
DateTime? _firstFlipTime;
|
||||
DateTime? _lastFlipTime;
|
||||
StreamSubscription<GyroscopeEvent>? _gyroSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final auth = AuthService();
|
||||
_api = ApiService(auth);
|
||||
_ptt = PttService();
|
||||
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
);
|
||||
_pulseAnim = Tween<double>(begin: 1.0, end: 1.12).animate(
|
||||
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
|
||||
);
|
||||
_pulseAnim = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.12,
|
||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
|
||||
_ptt.stateStream.listen((s) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -60,44 +74,105 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(err,
|
||||
style: const TextStyle(fontSize: 11), textAlign: TextAlign.center),
|
||||
content: Text(
|
||||
err,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: const Color(0xFF333333),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
WakelockPlus.enable();
|
||||
_connect();
|
||||
_checkGyroscope();
|
||||
}
|
||||
|
||||
Future<void> _checkGyroscope() async {
|
||||
try {
|
||||
final sub = gyroscopeEventStream().listen((_) {});
|
||||
await sub.cancel();
|
||||
if (mounted) setState(() => _wristAvailable = true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _toggleWrist() {
|
||||
setState(() => _wristEnabled = !_wristEnabled);
|
||||
|
||||
if (_wristEnabled) {
|
||||
_gyroSub = gyroscopeEventStream().listen(_onGyroscope);
|
||||
} else {
|
||||
_gyroSub?.cancel();
|
||||
_gyroSub = null;
|
||||
_firstFlipTime = null;
|
||||
_lastFlipTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onGyroscope(GyroscopeEvent event) {
|
||||
final magnitude = sqrt(
|
||||
event.x * event.x + event.y * event.y + event.z * event.z,
|
||||
);
|
||||
|
||||
if (magnitude < _gyroThreshold) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
if (_lastFlipTime != null &&
|
||||
now.difference(_lastFlipTime!) < _flipCooldown) {
|
||||
return;
|
||||
}
|
||||
_lastFlipTime = now;
|
||||
|
||||
if (_firstFlipTime == null ||
|
||||
now.difference(_firstFlipTime!) > _flipWindow) {
|
||||
_firstFlipTime = now;
|
||||
} else {
|
||||
_firstFlipTime = null;
|
||||
_onPttTap();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
final creds = await _api.getLivekitToken(widget.channel.id);
|
||||
final token = await AuthService().getToken();
|
||||
if (!mounted) return;
|
||||
if (creds == null) {
|
||||
if (token == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('دریافت توکن ناموفق بود',
|
||||
style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
|
||||
content: const Text(
|
||||
'لطفاً دوباره وارد شوید',
|
||||
style: TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: const Color(0xFF333333),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _ptt.connect(creds.url, creds.token);
|
||||
await _ptt.connectToGroup(
|
||||
widget.channel.id,
|
||||
token,
|
||||
AppConfig.livekitUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WakelockPlus.disable();
|
||||
_gyroSub?.cancel();
|
||||
_ptt.dispose();
|
||||
_pulseCtrl.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -105,8 +180,10 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
|
||||
Future<void> _onPttTap() async {
|
||||
if (_state == PttState.connected) {
|
||||
HapticFeedback.heavyImpact();
|
||||
await _ptt.startSpeaking();
|
||||
} else if (_state == PttState.speaking) {
|
||||
HapticFeedback.mediumImpact();
|
||||
await _ptt.stopSpeaking();
|
||||
}
|
||||
}
|
||||
|
|
@ -116,29 +193,12 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Back button — top-left
|
||||
Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.white38, size: 16),
|
||||
),
|
||||
),
|
||||
|
||||
// Main content
|
||||
Column(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Channel name
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
widget.channel.name,
|
||||
textAlign: TextAlign.center,
|
||||
|
|
@ -152,7 +212,7 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// PTT Button
|
||||
Center(
|
||||
|
|
@ -166,10 +226,57 @@ class _ChannelScreenState extends State<ChannelScreen>
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Status text
|
||||
_StatusText(state: _state, speaker: _speaker),
|
||||
// Bottom bar: back + wrist-flip toggle
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Back button
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
|
||||
// Wrist-flip toggle (فقط اگه ژیروسکوپ دارد)
|
||||
if (_wristAvailable) ...[
|
||||
const SizedBox(width: 16),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _wristEnabled
|
||||
? const Color(0xFF00C853).withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _toggleWrist,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.rotate_right,
|
||||
color: _wristEnabled
|
||||
? const Color(0xFF00C853)
|
||||
: Colors.white38,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -189,11 +296,7 @@ class _PttButton extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, icon, label) = switch (state) {
|
||||
PttState.speaking => (
|
||||
const Color(0xFFFF1744),
|
||||
Icons.mic,
|
||||
'TALKING'
|
||||
),
|
||||
PttState.speaking => (const Color(0xFFFF1744), Icons.mic, 'TALKING'),
|
||||
PttState.receiving => (
|
||||
const Color(0xFF2979FF),
|
||||
Icons.volume_up,
|
||||
|
|
@ -204,11 +307,7 @@ class _PttButton extends StatelessWidget {
|
|||
Icons.settings_input_antenna,
|
||||
'PUSH',
|
||||
),
|
||||
PttState.idle => (
|
||||
const Color(0xFF424242),
|
||||
Icons.wifi_off,
|
||||
'...',
|
||||
),
|
||||
PttState.idle => (const Color(0xFF424242), Icons.wifi_off, '...'),
|
||||
};
|
||||
|
||||
final bool enabled =
|
||||
|
|
@ -216,24 +315,24 @@ class _PttButton extends StatelessWidget {
|
|||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: 130,
|
||||
height: 130,
|
||||
width: 110,
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: enabled ? 0.45 : 0.15),
|
||||
blurRadius: state == PttState.speaking ? 24 : 10,
|
||||
spreadRadius: state == PttState.speaking ? 6 : 2,
|
||||
blurRadius: state == PttState.speaking ? 20 : 8,
|
||||
spreadRadius: state == PttState.speaking ? 4 : 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 38),
|
||||
const SizedBox(height: 4),
|
||||
Icon(icon, color: Colors.white, size: 32),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
|
|
@ -242,7 +341,7 @@ class _PttButton extends StatelessWidget {
|
|||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
|
|
@ -251,30 +350,3 @@ class _PttButton extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
class _StatusText extends StatelessWidget {
|
||||
final PttState state;
|
||||
final String? speaker;
|
||||
|
||||
const _StatusText({required this.state, this.speaker});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (text, color) = switch (state) {
|
||||
PttState.speaking => ('برای قطع کلیک کنید', const Color(0xFFFF1744)),
|
||||
PttState.receiving => (
|
||||
'${speaker ?? '...'} در حال صحبت',
|
||||
const Color(0xFF2979FF)
|
||||
),
|
||||
PttState.connected => ('برای صحبت کلیک کنید', Colors.white38),
|
||||
PttState.idle => ('در حال اتصال...', Colors.white24),
|
||||
};
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontSize: 10),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ class LoginScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _tokenCtrl = TextEditingController();
|
||||
final _usernameCtrl = TextEditingController();
|
||||
final _secretCtrl = TextEditingController();
|
||||
final _authService = AuthService();
|
||||
late final ApiService _api;
|
||||
bool _loading = false;
|
||||
|
|
@ -25,14 +26,16 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenCtrl.dispose();
|
||||
_usernameCtrl.dispose();
|
||||
_secretCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final token = _tokenCtrl.text.trim();
|
||||
if (token.isEmpty) {
|
||||
setState(() => _error = 'توکن را وارد کنید');
|
||||
final username = _usernameCtrl.text.trim();
|
||||
final secret = _secretCtrl.text.trim();
|
||||
if (username.isEmpty || secret.isEmpty) {
|
||||
setState(() => _error = 'نام کاربری و کلید را وارد کنید');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
|
|
@ -40,7 +43,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
_error = null;
|
||||
});
|
||||
|
||||
final ok = await _api.login(token);
|
||||
final ok = await _api.login(username, secret);
|
||||
if (!mounted) return;
|
||||
|
||||
if (ok) {
|
||||
|
|
@ -51,7 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
} else {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'توکن نادرست است';
|
||||
_error = 'نام کاربری یا کلید نادرست است';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -61,16 +64,24 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height -
|
||||
MediaQuery.of(context).padding.top -
|
||||
MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00C853).withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
|
|
@ -78,40 +89,67 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
child: const Icon(
|
||||
Icons.settings_input_antenna,
|
||||
color: Color(0xFF00C853),
|
||||
size: 22,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'WalkieTalkie',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Token input
|
||||
// Username input
|
||||
SizedBox(
|
||||
height: 36,
|
||||
height: 32,
|
||||
child: TextField(
|
||||
controller: _tokenCtrl,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
controller: _usernameCtrl,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'توکن ورود',
|
||||
hintText: 'نام کاربری',
|
||||
hintStyle:
|
||||
const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
const TextStyle(color: Colors.white38, fontSize: 10),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1C1C1E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 0),
|
||||
horizontal: 10, vertical: 0),
|
||||
),
|
||||
onSubmitted: (_) =>
|
||||
FocusScope.of(context).nextFocus(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Secret input
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: TextField(
|
||||
controller: _secretCtrl,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'کلید ورود',
|
||||
hintStyle:
|
||||
const TextStyle(color: Colors.white38, fontSize: 10),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1C1C1E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 0),
|
||||
),
|
||||
onSubmitted: (_) => _login(),
|
||||
),
|
||||
|
|
@ -119,12 +157,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
|
||||
// Error
|
||||
SizedBox(
|
||||
height: 18,
|
||||
height: 16,
|
||||
child: _error != null
|
||||
? Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFF1744), fontSize: 10),
|
||||
color: Color(0xFFFF1744), fontSize: 9),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -134,8 +172,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
onTap: _loading ? null : _login,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 60,
|
||||
height: 60,
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _loading
|
||||
? const Color(0xFF424242)
|
||||
|
|
@ -146,35 +184,37 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00C853).withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _loading
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.login, color: Colors.white, size: 26),
|
||||
: const Icon(Icons.login, color: Colors.white, size: 22),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'ورود',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 10),
|
||||
style: TextStyle(color: Colors.white38, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// Expected API:
|
||||
// POST /api/auth/login body: {"token":"..."} → {"user_id":"..."}
|
||||
// GET /api/channels header: Authorization: Bearer TOKEN
|
||||
// → [{"id":"1","name":"Alpha","member_count":3}]
|
||||
// POST /api/channels/join body: {"key":"..."} → {"id":"...","name":"..."}
|
||||
// GET /api/channels/{id}/livekit-token → {"url":"wss://...","token":"eyJ..."}
|
||||
// Backend API endpoints:
|
||||
// POST /auth/login body: {"username":"...","secret":"..."}
|
||||
// → {"access_token":"...","token_type":"bearer"}
|
||||
// GET /groups/me header: Authorization: Bearer TOKEN
|
||||
// → [{"id":"uuid","name":"...","description":"...","is_active":true}]
|
||||
//
|
||||
// LiveKit token از طریق WebSocket دریافت میشود:
|
||||
// WS /ws/groups/{id}?token={jwt}
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
|
@ -11,20 +13,13 @@ import '../config/app_config.dart';
|
|||
import '../models/channel.dart';
|
||||
import 'auth_service.dart';
|
||||
|
||||
// ── LiveKit credentials model ─────────────────────────────────────────────
|
||||
class LivekitCredentials {
|
||||
final String url;
|
||||
final String token;
|
||||
const LivekitCredentials({required this.url, required this.token});
|
||||
}
|
||||
|
||||
// ── Fake data (only used when AppConfig.debug == true) ────────────────────
|
||||
const _fakeChannels = [
|
||||
{'id': '1', 'name': 'تیم آلفا', 'member_count': 4},
|
||||
{'id': '2', 'name': 'پشتیبانی میدانی', 'member_count': 2},
|
||||
{'id': '3', 'name': 'فرماندهی', 'member_count': 7},
|
||||
{'id': '4', 'name': 'گروه لجستیک', 'member_count': 3},
|
||||
{'id': '5', 'name': 'واحد امنیت', 'member_count': 5},
|
||||
{'id': '1', 'name': 'تیم آلفا'},
|
||||
{'id': '2', 'name': 'پشتیبانی میدانی'},
|
||||
{'id': '3', 'name': 'فرماندهی'},
|
||||
{'id': '4', 'name': 'گروه لجستیک'},
|
||||
{'id': '5', 'name': 'واحد امنیت'},
|
||||
];
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -41,28 +36,47 @@ class ApiService {
|
|||
};
|
||||
}
|
||||
|
||||
Future<bool> login(String token) async {
|
||||
// استخراج user_id از JWT token (بدون نیاز به کتابخانه)
|
||||
String? _extractUserIdFromJwt(String token) {
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
// base64url decode payload
|
||||
var payload = parts[1];
|
||||
// اضافه کردن padding اگه لازمه
|
||||
while (payload.length % 4 != 0) {
|
||||
payload += '=';
|
||||
}
|
||||
final decoded = utf8.decode(base64Url.decode(payload));
|
||||
final map = jsonDecode(decoded) as Map<String, dynamic>;
|
||||
return map['sub']?.toString();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> login(String username, String secret) async {
|
||||
if (AppConfig.debug) {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
await _auth.saveAuth(token: token, userId: 'debug_user');
|
||||
await _auth.saveAuth(token: 'debug_jwt', userId: 'debug_user');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.post(
|
||||
Uri.parse('${AppConfig.baseUrl}/api/auth/login'),
|
||||
Uri.parse('${AppConfig.baseUrl}/auth/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'token': token}),
|
||||
body: jsonEncode({'username': username, 'secret': secret}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
await _auth.saveAuth(
|
||||
token: token,
|
||||
userId: data['user_id']?.toString(),
|
||||
);
|
||||
final accessToken = data['access_token'] as String?;
|
||||
if (accessToken == null) return false;
|
||||
final userId = _extractUserIdFromJwt(accessToken);
|
||||
await _auth.saveAuth(token: accessToken, userId: userId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -82,7 +96,7 @@ class ApiService {
|
|||
try {
|
||||
final res = await http
|
||||
.get(
|
||||
Uri.parse('${AppConfig.baseUrl}/api/channels'),
|
||||
Uri.parse('${AppConfig.baseUrl}/users/me/groups'),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
|
@ -98,66 +112,4 @@ class ApiService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<Channel?> joinPrivateChannel(String key) async {
|
||||
if (AppConfig.debug) {
|
||||
await Future.delayed(const Duration(milliseconds: 700));
|
||||
// کلید صحیح در حالت دیباگ: هر چیزی غیر از "wrong"
|
||||
if (key.toLowerCase() == 'wrong') return null;
|
||||
return Channel(
|
||||
id: 'private_${key.hashCode}',
|
||||
name: 'کانال خصوصی ($key)',
|
||||
memberCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.post(
|
||||
Uri.parse('${AppConfig.baseUrl}/api/channels/join'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({'key': key}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
return Channel.fromJson(
|
||||
jsonDecode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<LivekitCredentials?> getLivekitToken(String channelId) async {
|
||||
if (AppConfig.debug) {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
return const LivekitCredentials(
|
||||
url: 'wss://fake.livekit.io',
|
||||
token: 'debug_livekit_token',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.get(
|
||||
Uri.parse(
|
||||
'${AppConfig.baseUrl}/api/channels/$channelId/livekit-token'),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
return LivekitCredentials(
|
||||
url: data['url'] as String,
|
||||
token: data['token'] as String,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
// LiveKit PTT protocol:
|
||||
// connect(url, token) → join LiveKit room (muted by default)
|
||||
// startSpeaking() → enable microphone → LiveKit publishes audio track
|
||||
// stopSpeaking() → disable microphone → LiveKit unpublishes track
|
||||
// Remote audio is played automatically by LiveKit SDK
|
||||
// ActiveSpeakersChangedEvent fires whenever a participant starts/stops speaking
|
||||
// PTT Protocol (WebSocket + LiveKit):
|
||||
//
|
||||
// 1. connectToGroup(groupId, jwtToken, livekitUrl)
|
||||
// → WS /ws/groups/{groupId}?token={jwtToken}
|
||||
// → دریافت {"type":"livekit_token","token":"..."} از سرور
|
||||
// → اتصال به LiveKit با listener token (can_publish=false)
|
||||
//
|
||||
// 2. startSpeaking()
|
||||
// → ارسال {"type":"request_speak"} روی WS
|
||||
// → دریافت {"type":"speaker_granted","token":"..."} یا {"type":"speaker_busy","speaker":"..."}
|
||||
// → اگه granted: اتصال مجدد LiveKit با speaker token (can_publish=true) + روشن کردن میک
|
||||
//
|
||||
// 3. stopSpeaking()
|
||||
// → خاموش کردن میک
|
||||
// → ارسال {"type":"stop_speak"} روی WS
|
||||
//
|
||||
// Broadcasts از سرور:
|
||||
// {"type":"speaker","user_id":"..."} → کسی شروع به صحبت کرد
|
||||
// {"type":"speaker_released"} → خط آزاد شد
|
||||
// {"type":"presence","users":[...]} → لیست آنلاینها
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
|
||||
|
|
@ -13,9 +30,16 @@ import '../config/app_config.dart';
|
|||
enum PttState { idle, connected, speaking, receiving }
|
||||
|
||||
class PttService {
|
||||
WebSocket? _ws;
|
||||
Room? _room;
|
||||
EventsListener<RoomEvent>? _listener;
|
||||
|
||||
String? _livekitUrl;
|
||||
|
||||
// completers برای دریافت async پیامهای WS
|
||||
Completer<String?>? _listenerTokenCompleter;
|
||||
Completer<String?>? _speakGrantCompleter;
|
||||
|
||||
final _stateCtrl = StreamController<PttState>.broadcast();
|
||||
final _speakerCtrl = StreamController<String?>.broadcast();
|
||||
final _errorCtrl = StreamController<String>.broadcast();
|
||||
|
|
@ -34,15 +58,123 @@ class PttService {
|
|||
_stateCtrl.add(s);
|
||||
}
|
||||
|
||||
// ── Connect ──────────────────────────────────────────────────────────────
|
||||
Future<bool> connect(String url, String token) async {
|
||||
// ── اتصال به گروه ────────────────────────────────────────────────────────
|
||||
Future<bool> connectToGroup(
|
||||
String groupId,
|
||||
String jwtToken,
|
||||
String livekitUrl,
|
||||
) async {
|
||||
if (AppConfig.debug) {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
_setState(PttState.connected);
|
||||
return true;
|
||||
}
|
||||
|
||||
_livekitUrl = livekitUrl;
|
||||
|
||||
try {
|
||||
final wsUrl =
|
||||
'${AppConfig.wsBaseUrl}/ws/groups/$groupId?token=$jwtToken';
|
||||
_ws = await WebSocket.connect(wsUrl);
|
||||
|
||||
_listenerTokenCompleter = Completer<String?>();
|
||||
|
||||
_ws!.listen(
|
||||
(data) {
|
||||
if (data is String) {
|
||||
final msg = jsonDecode(data) as Map<String, dynamic>;
|
||||
_handleWsMessage(msg);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
_listenerTokenCompleter?.complete(null);
|
||||
if (_state != PttState.idle) {
|
||||
_setState(PttState.idle);
|
||||
_errorCtrl.add('اتصال به سرور قطع شد');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_listenerTokenCompleter?.complete(null);
|
||||
if (_state != PttState.idle) {
|
||||
_setState(PttState.idle);
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
// منتظر دریافت listener token از سرور
|
||||
final listenerToken = await _listenerTokenCompleter!.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
_listenerTokenCompleter = null;
|
||||
|
||||
if (listenerToken == null) {
|
||||
_errorCtrl.add('دریافت توکن ناموفق بود');
|
||||
return false;
|
||||
}
|
||||
|
||||
return await _connectLiveKit(livekitUrl, listenerToken);
|
||||
} catch (_) {
|
||||
_errorCtrl.add('اتصال به سرور برقرار نشد');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── پردازش پیامهای WebSocket ─────────────────────────────────────────────
|
||||
void _handleWsMessage(Map<String, dynamic> msg) {
|
||||
final type = msg['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case 'livekit_token':
|
||||
// اولین پیام بعد از اتصال: listener token
|
||||
if (_listenerTokenCompleter != null &&
|
||||
!_listenerTokenCompleter!.isCompleted) {
|
||||
_listenerTokenCompleter!.complete(msg['token'] as String?);
|
||||
}
|
||||
|
||||
case 'speaker_granted':
|
||||
// درخواست صحبت تایید شد
|
||||
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
|
||||
_speakGrantCompleter!.complete(msg['token'] as String?);
|
||||
}
|
||||
|
||||
case 'speaker_busy':
|
||||
// خط اشغاله
|
||||
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
|
||||
_speakGrantCompleter!.complete(null);
|
||||
}
|
||||
_errorCtrl.add('خط اشغال است');
|
||||
|
||||
case 'speaker':
|
||||
// broadcast: کسی مشغول صحبت شد
|
||||
if (_state != PttState.speaking) {
|
||||
final userId = msg['user_id'] as String?;
|
||||
_speakerName = userId;
|
||||
_speakerCtrl.add(userId);
|
||||
_setState(PttState.receiving);
|
||||
}
|
||||
|
||||
case 'speaker_released':
|
||||
// broadcast: خط آزاد شد
|
||||
if (_state == PttState.receiving) {
|
||||
_speakerName = null;
|
||||
_speakerCtrl.add(null);
|
||||
_setState(PttState.connected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── اتصال به LiveKit ──────────────────────────────────────────────────────
|
||||
Future<bool> _connectLiveKit(
|
||||
String url,
|
||||
String token, {
|
||||
bool setConnectedState = true,
|
||||
}) async {
|
||||
try {
|
||||
await _listener?.dispose();
|
||||
await _room?.disconnect();
|
||||
|
||||
_room = Room(
|
||||
roomOptions: const RoomOptions(
|
||||
adaptiveStream: false,
|
||||
|
|
@ -56,29 +188,25 @@ class PttService {
|
|||
(e) => _onSpeakersChanged(e.speakers),
|
||||
)
|
||||
..on<RoomDisconnectedEvent>((_) {
|
||||
_setState(PttState.idle);
|
||||
if (_state != PttState.speaking) _setState(PttState.idle);
|
||||
});
|
||||
|
||||
await _room!.connect(url, token);
|
||||
|
||||
// Start muted — mic only enabled when PTT is pressed
|
||||
await _room!.localParticipant?.setMicrophoneEnabled(false);
|
||||
|
||||
_setState(PttState.connected);
|
||||
if (setConnectedState) _setState(PttState.connected);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorCtrl.add('اتصال به سرور برقرار نشد');
|
||||
} catch (_) {
|
||||
_errorCtrl.add('اتصال به سرور صوتی برقرار نشد');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Speakers detection ────────────────────────────────────────────────────
|
||||
// ── تشخیص متکلم ──────────────────────────────────────────────────────────
|
||||
void _onSpeakersChanged(List<Participant> speakers) {
|
||||
// Ignore while I'm the one speaking
|
||||
if (_state == PttState.speaking) return;
|
||||
|
||||
final remoteSpeakers =
|
||||
speakers.whereType<RemoteParticipant>().toList();
|
||||
final remoteSpeakers = speakers.whereType<RemoteParticipant>().toList();
|
||||
|
||||
if (remoteSpeakers.isNotEmpty) {
|
||||
final p = remoteSpeakers.first;
|
||||
|
|
@ -92,7 +220,7 @@ class PttService {
|
|||
}
|
||||
}
|
||||
|
||||
// ── PTT ───────────────────────────────────────────────────────────────────
|
||||
// ── PTT: شروع صحبت ────────────────────────────────────────────────────────
|
||||
Future<void> startSpeaking() async {
|
||||
if (_state != PttState.connected) return;
|
||||
|
||||
|
|
@ -101,14 +229,35 @@ class PttService {
|
|||
return;
|
||||
}
|
||||
|
||||
_speakGrantCompleter = Completer<String?>();
|
||||
_ws?.add(jsonEncode({'type': 'request_speak'}));
|
||||
|
||||
try {
|
||||
final speakerToken = await _speakGrantCompleter!.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
|
||||
if (speakerToken == null) return; // پیام خطا قبلاً emit شده
|
||||
|
||||
// اتصال مجدد LiveKit با speaker token
|
||||
final ok = await _connectLiveKit(
|
||||
_livekitUrl!,
|
||||
speakerToken,
|
||||
setConnectedState: false,
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
||||
_setState(PttState.speaking);
|
||||
} catch (_) {
|
||||
_errorCtrl.add('خطا در فعالسازی میکروفون');
|
||||
} finally {
|
||||
_speakGrantCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── PTT: پایان صحبت ──────────────────────────────────────────────────────
|
||||
Future<void> stopSpeaking() async {
|
||||
if (_state != PttState.speaking) return;
|
||||
|
||||
|
|
@ -120,10 +269,10 @@ class PttService {
|
|||
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
||||
} catch (_) {}
|
||||
|
||||
_ws?.add(jsonEncode({'type': 'stop_speak'}));
|
||||
_setState(PttState.connected);
|
||||
} catch (_) {
|
||||
_setState(PttState.connected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug helper ──────────────────────────────────────────────────────────
|
||||
|
|
@ -141,7 +290,7 @@ class PttService {
|
|||
_setState(PttState.connected);
|
||||
}
|
||||
|
||||
// ── Disconnect / Dispose ──────────────────────────────────────────────────
|
||||
// ── قطع اتصال ────────────────────────────────────────────────────────────
|
||||
Future<void> disconnect() async {
|
||||
if (AppConfig.debug) {
|
||||
_setState(PttState.idle);
|
||||
|
|
@ -156,6 +305,10 @@ class PttService {
|
|||
await _room?.disconnect();
|
||||
_room = null;
|
||||
_listener = null;
|
||||
|
||||
await _ws?.close();
|
||||
_ws = null;
|
||||
|
||||
_setState(PttState.idle);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ import connectivity_plus
|
|||
import device_info_plus
|
||||
import flutter_webrtc
|
||||
import livekit_client
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -528,6 +544,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
sensors_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sensors_plus
|
||||
sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sensors_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sensors_plus_platform_interface
|
||||
sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -677,6 +709,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
wakelock_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ dependencies:
|
|||
path_provider: ^2.1.5
|
||||
shared_preferences: ^2.5.4
|
||||
http: ^1.6.0
|
||||
sensors_plus: ^7.0.0
|
||||
wakelock_plus: ^1.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user