avatar_kit 1.0.0-beta.26 copy "avatar_kit: ^1.0.0-beta.26" to clipboard
avatar_kit: ^1.0.0-beta.26 copied to clipboard

A Flutter plugin for AvatarKit, which provides high-performance avatar rendering with real-time and audio-driven capabilities.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart' hide ConnectionState, Transform;
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:avatar_kit/avatar_kit.dart';

const List<Map<String, String>> presetAvatars = [
  {'name': 'Avatar 1', 'id': 'c067bb81-93cc-4a39-9622-9fb1c593cda6'},
  {'name': 'Avatar 2', 'id': 'c067bb81-93cc-4a39-9622-9fb1c593cda6'},
  {'name': 'Avatar 3', 'id': 'c067bb81-93cc-4a39-9622-9fb1c593cda6'},
  {'name': 'Avatar 4', 'id': 'c067bb81-93cc-4a39-9622-9fb1c593cda6'},
];

const List<String> audioFiles = [
  'audio_01.pcm',
  'audio_02.pcm',
  'audio_03.pcm',
];

// --- AppConfig ---

class AppConfig {
  String appID;
  String sessionToken;
  String userID;
  String avatarID;
  String environment;
  int sampleRate;
  String drivingServiceMode;

  AppConfig({
    this.appID = 'Demo',
    this.sessionToken = '',
    this.userID = '',
    this.avatarID = 'c067bb81-93cc-4a39-9622-9fb1c593cda6',
    this.environment = 'intl',
    this.sampleRate = 16000,
    this.drivingServiceMode = 'sdk',
  });

  static Future<AppConfig> load() async {
    final prefs = await SharedPreferences.getInstance();
    return AppConfig(
      appID: prefs.getString('appID') ?? 'Demo',
      sessionToken: prefs.getString('sessionToken') ?? '',
      userID: prefs.getString('userID') ?? '',
      avatarID: prefs.getString('avatarID') ?? presetAvatars[0]['id']!,
      environment: prefs.getString('environment') ?? 'intl',
      sampleRate: prefs.getInt('sampleRate') ?? 16000,
      drivingServiceMode: prefs.getString('drivingServiceMode') ?? 'sdk',
    );
  }

  Future<void> save() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('appID', appID);
    await prefs.setString('sessionToken', sessionToken);
    await prefs.setString('userID', userID);
    await prefs.setString('avatarID', avatarID);
    await prefs.setString('environment', environment);
    await prefs.setInt('sampleRate', sampleRate);
    await prefs.setString('drivingServiceMode', drivingServiceMode);
  }

  String get avatarDisplayName {
    for (final preset in presetAvatars) {
      if (preset['id'] == avatarID) return preset['name']!;
    }
    return avatarID.length > 8 ? '${avatarID.substring(0, 8)}...' : avatarID;
  }
}

// --- Main ---

void main() {
  runApp(const AvatarKitApp());
}

class AvatarKitApp extends StatefulWidget {
  const AvatarKitApp({super.key});

  @override
  State<AvatarKitApp> createState() => _AvatarKitAppState();
}

class _AvatarKitAppState extends State<AvatarKitApp> {
  AppConfig? config;

  @override
  void initState() {
    super.initState();
    AppConfig.load().then((c) => setState(() => config = c));
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(useMaterial3: true),
      home: config == null
          ? const Scaffold(body: Center(child: CircularProgressIndicator()))
          : DemoPage(config: config!),
    );
  }
}

// --- Demo Page (matches iOS ContentView) ---

class DemoPage extends StatefulWidget {
  final AppConfig config;
  const DemoPage({super.key, required this.config});

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class _DemoPageState extends State<DemoPage> {
  late AppConfig config;

  // State
  Avatar? _avatar;
  AvatarController? _avatarController;
  bool _isLoading = false;
  String _loadingMessage = '';
  double _progress = 0;
  String? _errorMessage;
  ConnectionState _connectionState = ConnectionState.disconnected;
  ConversationState _conversationState = ConversationState.idle;
  bool _isRenderingPaused = false;
  bool _conversationActionPending = false;
  bool _isSendingAudio = false;
  String? _currentlyPlayingFile;
  Timer? _sendAudioTimer;
  int _sizeMode = 0; // 0=Full, 1=Wide, 2=Small
  bool _perfEnabled = true;
  FrameRateInfo? _frameRateInfo;
  String _runtimeAppID = '-';
  int _runtimeTokenLength = 0;
  String _runtimeEnvironment = '-';
  bool _sdkInitialized = false;
  String _initializedAppID = '';
  String _initializedEnvironment = '';

  bool get _isConnected => _connectionState == ConnectionState.connected;

  @override
  void initState() {
    super.initState();
    config = widget.config;
    _runtimeEnvironment = config.environment;
  }

  @override
  void dispose() {
    _sendAudioTimer?.cancel();
    super.dispose();
  }

  void _initializeSDK() {
    if (!_sdkInitialized) {
      final env = Environment.values.firstWhere(
        (e) => e.name == config.environment,
        orElse: () => Environment.intl,
      );
      final mode = config.drivingServiceMode == 'host'
          ? DrivingServiceMode.host
          : DrivingServiceMode.sdk;
      AvatarSDK.initialize(
        appID: config.appID,
        configuration: Configuration(
          environment: env,
          audioFormat: AudioFormat(sampleRate: config.sampleRate),
          drivingServiceMode: mode,
          logLevel: LogLevel.all,
        ),
      );
      _sdkInitialized = true;
      _initializedAppID = config.appID;
      _initializedEnvironment = config.environment;
    }

    AvatarSDK.setSessionToken(config.sessionToken);
    AvatarSDK.setUserID(config.userID);
    _refreshRuntimeIdentity();
  }

  Future<void> _refreshRuntimeIdentity() async {
    final runtimeAppID = await AvatarSDK.appID();
    final runtimeToken = await AvatarSDK.sessionToken();
    final runtimeConfig = await AvatarSDK.configuration();
    if (!mounted) return;
    setState(() {
      _runtimeAppID = runtimeAppID;
      _runtimeTokenLength = runtimeToken.length;
      _runtimeEnvironment = runtimeConfig.environment.name;
    });
  }

  // --- Settings ---

  void _showSettings() async {
    final result = await showModalBottomSheet<AppConfig>(
      context: context,
      isScrollControlled: true,
      useSafeArea: true,
      builder: (context) => ConfigSheet(config: config),
    );
    if (result != null) {
      await result.save();
      setState(() {
        config = result;
        _avatar = null;
        _avatarController = null;
        _connectionState = ConnectionState.disconnected;
        _conversationState = ConversationState.idle;
        _errorMessage = null;
        _isRenderingPaused = false;
        _conversationActionPending = false;
        _perfEnabled = true;
        _frameRateInfo = null;
        if (_sdkInitialized &&
            (result.appID != _initializedAppID ||
                result.environment != _initializedEnvironment)) {
          _errorMessage =
              'SDK 已初始化为 appID=$_initializedAppID, env=$_initializedEnvironment。iOS 需重启 App 才能切换 appID/env。';
        }
      });
      _initializeSDK();
    }
  }

  // --- Avatar Loading ---

  void _loadAvatar() async {
    final avatarID = config.avatarID;
    if (avatarID.isEmpty) {
      setState(() => _errorMessage = 'Avatar ID is empty');
      return;
    }

    setState(() {
      _isLoading = true;
      _errorMessage = null;
      _loadingMessage = 'Loading...';
      _progress = 0;
    });

    _initializeSDK();

    try {
      Avatar? avatar = await AvatarManager.shared.retrieve(id: avatarID);
      if (avatar != null) {
        setState(() => _loadingMessage = 'Loaded from cache');
      } else {
        avatar = await AvatarManager.shared.load(
          id: avatarID,
          onProgress: (progress) {
            setState(() {
              _progress = progress;
              _loadingMessage = 'Downloading... ${(progress * 100).toInt()}%';
            });
          },
        );
        setState(() => _loadingMessage = 'Loaded');
      }
      setState(() {
        _avatar = avatar;
        _isLoading = false;
      });
    } on AvatarError catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = e.name;
        _loadingMessage = 'Failed';
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = e.toString();
        _loadingMessage = 'Failed';
      });
    }
  }

  // --- Platform View Created ---

  void _onPlatformViewCreated(AvatarController controller) async {
    controller.onFirstRendering = () {
      debugPrint('onFirstRendering');
      setState(() => _loadingMessage = 'Rendering');
    };
    controller.onConnectionState = (state, errorMessage) {
      debugPrint(
        'onConnectionState: ${state.name}${errorMessage != null ? ' - $errorMessage' : ''}',
      );
      setState(() {
        _connectionState = state;
        if (state == ConnectionState.failed && errorMessage != null) {
          _errorMessage = errorMessage;
        }
      });
    };
    controller.onConversationState = (state) {
      debugPrint('onConversationState: ${state.name}');
      setState(() {
        _conversationState = state;
        _conversationActionPending = false;
      });
    };
    controller.onError = (error) {
      debugPrint('onError: ${error.name}');
      setState(() => _errorMessage = error.name);
    };
    controller.onFrameRateInfo = (info) {
      if (!mounted) return;
      setState(() => _frameRateInfo = info);
    };

    await controller.setVolume(1.0);
    await controller.setOpaque(false);
    await controller.setContentTransform(Transform.identity);
    await controller.setFrameRateMonitorEnabled(_perfEnabled);

    final pointCount = await controller.pointCount();
    debugPrint('pointCount: $pointCount');

    setState(() => _avatarController = controller);
  }

  // --- Controls ---

  void _start() async {
    final token = await AvatarSDK.sessionToken();
    final appID = await AvatarSDK.appID();
    debugPrint(
      '[Demo] start: appID=$appID, token=${token.isEmpty ? "EMPTY" : "${token.length} chars"}',
    );
    setState(() {
      _runtimeAppID = appID;
      _runtimeTokenLength = token.length;
      _runtimeEnvironment = config.environment;
      _conversationActionPending = false;
    });
    _avatarController?.start();
  }

  void _stop() {
    _cancelAudioSending();
    _avatarController?.close();
    setState(() {
      _connectionState = ConnectionState.disconnected;
      _conversationState = ConversationState.idle;
      _conversationActionPending = false;
    });
  }

  void _interrupt() {
    _cancelAudioSending();
    _avatarController?.interrupt();
    setState(() {
      _conversationActionPending = false;
      _conversationState = ConversationState.idle;
    });
  }

  void _pauseConversation() async {
    if (_avatarController == null ||
        !_isConnected ||
        _conversationActionPending ||
        _conversationState != ConversationState.playing) {
      return;
    }
    setState(() => _conversationActionPending = true);
    try {
      await _avatarController!.pause();
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _conversationActionPending = false;
        _errorMessage = 'Pause failed: $e';
      });
    }
  }

  void _resumeConversation() async {
    if (_avatarController == null ||
        !_isConnected ||
        _conversationActionPending ||
        _conversationState != ConversationState.paused) {
      return;
    }
    setState(() => _conversationActionPending = true);
    try {
      await _avatarController!.resume();
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _conversationActionPending = false;
        _errorMessage = 'Resume failed: $e';
      });
    }
  }

  void _pauseRendering() {
    _avatarController?.pauseRendering();
    setState(() => _isRenderingPaused = true);
  }

  void _resumeRendering() {
    _avatarController?.resumeRendering();
    setState(() => _isRenderingPaused = false);
  }

  void _togglePerfMonitor() async {
    final next = !_perfEnabled;
    await _avatarController?.setFrameRateMonitorEnabled(next);
    setState(() {
      _perfEnabled = next;
      if (!next) {
        _frameRateInfo = null;
      }
    });
  }

  // --- Audio Sending ---

  void _sendAudioFile(String filename) async {
    if (!_isConnected || _avatarController == null) return;

    _cancelAudioSending();
    _avatarController!.interrupt();

    try {
      final data = await rootBundle.load('assets/$filename');
      final audioData = data.buffer.asUint8List();
      final chunkSize = config.sampleRate * 2; // 1 second of 16-bit mono PCM

      setState(() {
        _isSendingAudio = true;
        _currentlyPlayingFile = filename;
      });

      int offset = 0;
      _sendAudioTimer = Timer.periodic(const Duration(milliseconds: 100), (
        timer,
      ) {
        if (offset >= audioData.length) {
          timer.cancel();
          setState(() {
            _isSendingAudio = false;
            _currentlyPlayingFile = null;
          });
          return;
        }
        final end = (offset + chunkSize).clamp(0, audioData.length);
        final isLast = end >= audioData.length;
        final chunk = audioData.sublist(offset, end);
        _avatarController?.send(Uint8List.fromList(chunk), end: isLast);
        offset = end;
      });
    } catch (e) {
      setState(() {
        _errorMessage = 'Cannot read $filename';
        _isSendingAudio = false;
        _currentlyPlayingFile = null;
      });
    }
  }

  void _cancelAudioSending() {
    _sendAudioTimer?.cancel();
    _sendAudioTimer = null;
    setState(() {
      _isSendingAudio = false;
      _currentlyPlayingFile = null;
    });
  }

  // --- Host Mode: Mock Data ---

  final Map<int, _MockData> _mockData = {};

  Future<void> _loadMockData() async {
    if (_mockData.containsKey(config.sampleRate)) return;
    try {
      final response = await http.post(
        Uri.parse('https://python-sdk-mock-demo.spatialwalk.cn/generate'),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({'sample_rate': '${config.sampleRate}'}),
      );
      if (response.statusCode == 200) {
        setState(() {
          _mockData[config.sampleRate] = _MockData.fromJson(
            jsonDecode(response.body),
          );
        });
      }
    } catch (e) {
      debugPrint('Fetching mock data failed: $e');
    }
  }

  void _playMockStream() async {
    final data = _mockData[config.sampleRate];
    if (data == null || _avatarController == null) return;
    final conversationID = await _avatarController!.yieldAudioData(
      data.audio,
      end: true,
      audioFormat: AudioFormat(sampleRate: config.sampleRate),
    );
    for (var i = 0; i < data.animations.length; i++) {
      final animation = data.animations[i];
      Future.delayed(Duration(seconds: i + 1), () {
        _avatarController?.yieldAnimations([
          animation,
        ], conversationID: conversationID);
      });
    }
  }

  void _playMockAll() async {
    final data = _mockData[config.sampleRate];
    if (data == null || _avatarController == null) return;
    final conversationID = await _avatarController!.yieldAudioData(
      data.audio,
      end: true,
      audioFormat: AudioFormat(sampleRate: config.sampleRate),
    );
    _avatarController!.yieldAnimations(
      data.animations,
      conversationID: conversationID,
    );
  }

  // --- UI ---

  @override
  Widget build(BuildContext context) {
    final isHostMode = config.drivingServiceMode == 'host';

    return Scaffold(
      appBar: AppBar(
        title: Text('AvatarKit Demo${isHostMode ? ' (Host)' : ''}'),
        actions: [
          IconButton(
            icon: const Icon(Icons.grid_view),
            onPressed: () async {
              _avatarController?.pauseRendering();
              await Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => MultiInstancePage(config: config),
                ),
              );
              _avatarController?.resumeRendering();
              setState(() => _isRenderingPaused = false);
            },
          ),
          IconButton(
            icon: const Icon(Icons.settings),
            onPressed: _showSettings,
          ),
        ],
      ),
      body: SafeArea(
        child: Column(
          children: [
            // Avatar section
            _buildAvatarContainer(),

            if (_errorMessage != null)
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Text(
                  _errorMessage!,
                  style: const TextStyle(color: Colors.red, fontSize: 12),
                ),
              ),

            const Divider(height: 1),

            // Status section
            _buildStatusSection(),

            const Divider(height: 1),

            // Controls section
            _buildControlsSection(),

            const Divider(height: 1),

            // Audio / Host section
            Expanded(
              flex: 2,
              child: isHostMode
                  ? _buildHostSection()
                  : _buildAudioFilesSection(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildAvatarContainer() {
    final labels = ['Full', 'Wide', 'Small'];
    return Expanded(
      flex: 3,
      child: Column(
        children: [
          Expanded(
            child: _sizeMode == 0
                ? _buildAvatarSection()
                : _sizeMode == 1
                ? SizedBox(height: 250, child: _buildAvatarSection())
                : Center(
                    child: SizedBox(
                      width: 150,
                      height: 150,
                      child: _buildAvatarSection(),
                    ),
                  ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
            child: Row(
              children: [
                const Text(
                  'Size:',
                  style: TextStyle(fontSize: 12, color: Colors.grey),
                ),
                const SizedBox(width: 8),
                ...List.generate(
                  3,
                  (i) => Padding(
                    padding: const EdgeInsets.only(right: 4),
                    child: ChoiceChip(
                      label: Text(
                        labels[i],
                        style: const TextStyle(fontSize: 11),
                      ),
                      selected: _sizeMode == i,
                      onSelected: (_) => setState(() => _sizeMode = i),
                      visualDensity: VisualDensity.compact,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildAvatarSection() {
    if (_avatar != null) {
      return AvatarWidget(
        key: ValueKey('avatar_size_$_sizeMode'),
        avatar: _avatar!,
        onPlatformViewCreated: _onPlatformViewCreated,
      );
    }
    if (_isLoading) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const CircularProgressIndicator(),
            const SizedBox(height: 16),
            Text(_loadingMessage, style: const TextStyle(color: Colors.grey)),
            if (_progress > 0) ...[
              const SizedBox(height: 8),
              SizedBox(
                width: 200,
                child: LinearProgressIndicator(value: _progress),
              ),
            ],
          ],
        ),
      );
    }
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.person_outline, size: 60, color: Colors.grey[400]),
          const SizedBox(height: 16),
          Text(
            'Tap Initialize to load avatar',
            style: TextStyle(color: Colors.grey[500]),
          ),
        ],
      ),
    );
  }

  Widget _buildStatusSection() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Column(
        children: [
          Row(
            children: [
              _statusDot(_connectionStateColor),
              const SizedBox(width: 4),
              Text(_connectionStateName, style: const TextStyle(fontSize: 12)),
              const SizedBox(width: 20),
              _statusDot(_conversationStateColor),
              const SizedBox(width: 4),
              Text(
                _conversationState.name,
                style: const TextStyle(fontSize: 12),
              ),
              const Spacer(),
              Text(
                config.avatarDisplayName,
                style: const TextStyle(fontSize: 12, color: Colors.grey),
              ),
            ],
          ),
          const SizedBox(height: 4),
          Align(
            alignment: Alignment.centerLeft,
            child: Text(
              'SDK appID=$_runtimeAppID  env=$_runtimeEnvironment  tokenLen=$_runtimeTokenLength',
              style: const TextStyle(fontSize: 11, color: Colors.grey),
            ),
          ),
          if (_perfEnabled && _frameRateInfo != null) ...[
            const SizedBox(height: 6),
            _buildPerfInfoLine(_frameRateInfo!),
          ],
        ],
      ),
    );
  }

  Widget _buildPerfInfoLine(FrameRateInfo info) {
    Color fpsColor(double fps) {
      if (fps >= 23) return Colors.green;
      if (fps >= 15) return Colors.orange;
      return Colors.red;
    }

    return Row(
      children: [
        Text(
          'Prod ${info.productionFps.toStringAsFixed(1)}',
          style: TextStyle(fontSize: 11, color: fpsColor(info.productionFps)),
        ),
        const SizedBox(width: 8),
        Text(
          'Disp ${info.displayFps.toStringAsFixed(1)}',
          style: TextStyle(fontSize: 11, color: fpsColor(info.displayFps)),
        ),
        const SizedBox(width: 8),
        Text(
          'P95 ${info.frameP95Ms.toStringAsFixed(1)}ms',
          style: const TextStyle(fontSize: 11, color: Colors.grey),
        ),
        const SizedBox(width: 8),
        Text(
          'Jank ${info.jankRatio50Ms.toStringAsFixed(1)}%',
          style: const TextStyle(fontSize: 11, color: Colors.grey),
        ),
      ],
    );
  }

  Widget _statusDot(Color color) {
    return Container(
      width: 8,
      height: 8,
      decoration: BoxDecoration(color: color, shape: BoxShape.circle),
    );
  }

  String get _connectionStateName {
    switch (_connectionState) {
      case ConnectionState.disconnected:
        return 'Disconnected';
      case ConnectionState.connecting:
        return 'Connecting';
      case ConnectionState.connected:
        return 'Connected';
      case ConnectionState.failed:
        return 'Failed';
    }
  }

  Color get _connectionStateColor {
    switch (_connectionState) {
      case ConnectionState.disconnected:
        return Colors.grey;
      case ConnectionState.connecting:
        return Colors.orange;
      case ConnectionState.connected:
        return Colors.green;
      case ConnectionState.failed:
        return Colors.red;
    }
  }

  Color get _conversationStateColor {
    switch (_conversationState) {
      case ConversationState.idle:
        return Colors.grey;
      case ConversationState.playing:
        return Colors.green;
      case ConversationState.paused:
        return Colors.orange;
    }
  }

  Widget _buildControlsSection() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Column(
        children: [
          Row(
            children: [
              _ctrlBtn(
                'Initialize',
                Icons.download,
                _loadAvatar,
                enabled: !_isLoading,
              ),
              const SizedBox(width: 8),
              _isConnected
                  ? _ctrlBtn('Stop', Icons.stop_circle_outlined, _stop)
                  : _ctrlBtn(
                      'Start',
                      Icons.play_circle_outlined,
                      _start,
                      enabled: _avatar != null,
                    ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              _isRenderingPaused
                  ? _ctrlBtn('Show', Icons.visibility, _resumeRendering)
                  : _ctrlBtn(
                      'Hide',
                      Icons.visibility_off,
                      _pauseRendering,
                      enabled: _avatar != null,
                    ),
              const SizedBox(width: 8),
              _ctrlBtn(
                'Interrupt',
                Icons.cancel_outlined,
                _interrupt,
                enabled: _isConnected,
              ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              _conversationState == ConversationState.paused
                  ? _ctrlBtn(
                      'Play',
                      Icons.play_circle_filled,
                      _resumeConversation,
                      enabled: _isConnected && !_conversationActionPending,
                    )
                  : _ctrlBtn(
                      'Pause',
                      Icons.pause_circle_filled,
                      _pauseConversation,
                      enabled:
                          _isConnected &&
                          !_conversationActionPending &&
                          _conversationState == ConversationState.playing,
                    ),
              const SizedBox(width: 8),
              _ctrlBtn(
                _perfEnabled ? 'Perf ON' : 'Perf OFF',
                Icons.speed,
                _togglePerfMonitor,
                enabled: _avatarController != null,
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _ctrlBtn(
    String label,
    IconData icon,
    VoidCallback onPressed, {
    bool enabled = true,
  }) {
    return Expanded(
      child: OutlinedButton(
        onPressed: enabled ? onPressed : null,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 20),
            const SizedBox(height: 2),
            Text(label, style: const TextStyle(fontSize: 11)),
          ],
        ),
      ),
    );
  }

  // --- Audio Files Section (SDK mode) ---

  Widget _buildAudioFilesSection() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
          child: Row(
            children: [
              const Text(
                'Audio Files',
                style: TextStyle(fontSize: 12, color: Colors.grey),
              ),
              const Spacer(),
              if (_isSendingAudio)
                const SizedBox(
                  width: 16,
                  height: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                ),
            ],
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: audioFiles.length,
            itemBuilder: (context, index) {
              final file = audioFiles[index];
              final isPlaying = _currentlyPlayingFile == file;
              return ListTile(
                leading: const Icon(Icons.graphic_eq, size: 20),
                title: Text(file, style: const TextStyle(fontSize: 14)),
                trailing: isPlaying
                    ? const Icon(Icons.volume_up, color: Colors.blue, size: 20)
                    : null,
                dense: true,
                enabled: _isConnected && !_isSendingAudio,
                onTap: () => _sendAudioFile(file),
              );
            },
          ),
        ),
      ],
    );
  }

  // --- Host Section ---

  Widget _buildHostSection() {
    final hasData = _mockData.containsKey(config.sampleRate);
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Column(
        children: [
          Text(
            'Sample Rate: ${config.sampleRate} Hz',
            style: const TextStyle(fontSize: 12, color: Colors.grey),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: _loadMockData,
                  child: Text(
                    hasData ? 'Mock Data Loaded' : 'Load Mock Data',
                    style: const TextStyle(fontSize: 12),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: hasData ? _playMockStream : null,
                  child: const Text(
                    'Play Stream',
                    style: TextStyle(fontSize: 12),
                  ),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: OutlinedButton(
                  onPressed: hasData ? _playMockAll : null,
                  child: const Text('Playback', style: TextStyle(fontSize: 12)),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: _isConnected ? _interrupt : null,
                  child: const Text(
                    'Interrupt',
                    style: TextStyle(fontSize: 12),
                  ),
                ),
              ),
            ],
          ),
          const Spacer(),
          Text(
            'Conversation: ${_conversationState.name}',
            style: const TextStyle(fontSize: 12, color: Colors.grey),
          ),
        ],
      ),
    );
  }
}

// --- Config Sheet ---

class ConfigSheet extends StatefulWidget {
  final AppConfig config;
  const ConfigSheet({super.key, required this.config});

  @override
  State<ConfigSheet> createState() => _ConfigSheetState();
}

class _ConfigSheetState extends State<ConfigSheet> {
  late TextEditingController _appIDController;
  late TextEditingController _tokenController;
  late TextEditingController _userIDController;
  late TextEditingController _customAvatarIDController;
  late String _selectedEnvironment;
  late int _selectedSampleRate;
  late String _selectedAvatarID;
  late String _selectedMode;
  bool _isCustomAvatar = false;

  @override
  void initState() {
    super.initState();
    _appIDController = TextEditingController(text: widget.config.appID);
    _tokenController = TextEditingController(text: widget.config.sessionToken);
    _userIDController = TextEditingController(text: widget.config.userID);
    _selectedEnvironment = widget.config.environment;
    _selectedSampleRate = widget.config.sampleRate;
    _selectedAvatarID = widget.config.avatarID;
    _selectedMode = widget.config.drivingServiceMode;
    _isCustomAvatar = !presetAvatars.any((a) => a['id'] == _selectedAvatarID);
    _customAvatarIDController = TextEditingController(
      text: _isCustomAvatar ? _selectedAvatarID : '',
    );
  }

  @override
  void dispose() {
    _appIDController.dispose();
    _tokenController.dispose();
    _userIDController.dispose();
    _customAvatarIDController.dispose();
    super.dispose();
  }

  void _save() {
    final avatarID = _isCustomAvatar
        ? _customAvatarIDController.text.trim()
        : _selectedAvatarID;
    if (avatarID.isEmpty) return;

    Navigator.pop(
      context,
      AppConfig(
        appID: _appIDController.text.trim(),
        sessionToken: _tokenController.text.trim(),
        userID: _userIDController.text.trim(),
        avatarID: avatarID,
        environment: _selectedEnvironment,
        sampleRate: _selectedSampleRate,
        drivingServiceMode: _selectedMode,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Settings'),
        leading: TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        leadingWidth: 80,
        actions: [TextButton(onPressed: _save, child: const Text('Save'))],
      ),
      body: ListView(
        children: [
          // SDK Configuration
          _sectionHeader('SDK Configuration'),
          _textField(_appIDController, 'App ID'),
          _textField(_tokenController, 'Session Token', obscure: true),
          _textField(_userIDController, 'User ID'),
          _dropdownTile(
            'Environment',
            _selectedEnvironment,
            {'intl': 'International', 'cn': 'China'},
            (v) => setState(() => _selectedEnvironment = v),
          ),
          _dropdownTile(
            'Sample Rate',
            _selectedSampleRate.toString(),
            {
              for (var r in [8000, 16000, 22050, 24000, 32000, 44100, 48000])
                r.toString(): '$r Hz',
            },
            (v) => setState(() => _selectedSampleRate = int.parse(v)),
          ),
          _dropdownTile(
            'Driving Service Mode',
            _selectedMode,
            {'sdk': 'SDK', 'host': 'Host'},
            (v) => setState(() => _selectedMode = v),
          ),

          // Avatar
          _sectionHeader('Avatar'),
          ...presetAvatars.map(
            (avatar) => RadioListTile<String>(
              title: Text(avatar['name']!),
              subtitle: Text(
                avatar['id']!,
                style: const TextStyle(fontSize: 10),
              ),
              value: avatar['id']!,
              groupValue: _isCustomAvatar ? null : _selectedAvatarID,
              onChanged: (value) => setState(() {
                _isCustomAvatar = false;
                _selectedAvatarID = value!;
              }),
              dense: true,
            ),
          ),
          RadioListTile<bool>(
            title: const Text('Custom'),
            value: true,
            groupValue: _isCustomAvatar ? true : null,
            onChanged: (_) => setState(() => _isCustomAvatar = true),
            dense: true,
          ),
          if (_isCustomAvatar)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: TextField(
                controller: _customAvatarIDController,
                decoration: const InputDecoration(
                  labelText: 'Custom Avatar ID',
                ),
              ),
            ),
          const SizedBox(height: 32),
        ],
      ),
    );
  }

  Widget _sectionHeader(String title) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
      child: Text(
        title,
        style: TextStyle(
          fontSize: 13,
          fontWeight: FontWeight.w600,
          color: Colors.grey[600],
        ),
      ),
    );
  }

  Widget _textField(
    TextEditingController controller,
    String label, {
    bool obscure = false,
  }) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      child: TextField(
        controller: controller,
        decoration: InputDecoration(labelText: label, isDense: true),
        obscureText: obscure,
        autocorrect: false,
      ),
    );
  }

  Widget _dropdownTile(
    String label,
    String value,
    Map<String, String> options,
    ValueChanged<String> onChanged,
  ) {
    return ListTile(
      title: Text(label, style: const TextStyle(fontSize: 14)),
      trailing: DropdownButton<String>(
        value: value,
        underline: const SizedBox(),
        items: options.entries
            .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
            .toList(),
        onChanged: (v) {
          if (v != null) onChanged(v);
        },
      ),
      dense: true,
    );
  }
}

// --- Mock Data ---

class _MockData {
  final Uint8List audio;
  final List<Uint8List> animations;

  _MockData({required this.audio, required this.animations});

  factory _MockData.fromJson(Map<String, dynamic> json) {
    return _MockData(
      audio: base64Decode(json['audio_base64']),
      animations: (json['animation_messages_base64'] as List<dynamic>)
          .map<Uint8List>((animation) => base64Decode(animation as String))
          .toList(),
    );
  }
}

// --- Multi Instance Page ---

class MultiInstancePage extends StatefulWidget {
  final AppConfig config;
  const MultiInstancePage({super.key, required this.config});

  @override
  State<MultiInstancePage> createState() => _MultiInstancePageState();
}

class _MultiInstancePageState extends State<MultiInstancePage> {
  int _selectedTab = 0;
  final List<Avatar?> _avatars = List.filled(4, null);
  final List<AvatarController?> _controllers = List.filled(4, null);
  final List<bool> _loading = List.filled(4, false);
  final List<ConnectionState> _connStates = List.filled(
    4,
    ConnectionState.disconnected,
  );
  final List<ConversationState> _convStates = List.filled(
    4,
    ConversationState.idle,
  );
  final List<bool> _perfEnabled = List.filled(4, true);
  final List<FrameRateInfo?> _frameRateInfos = List<FrameRateInfo?>.filled(
    4,
    null,
  );

  void _switchTab(int nextTab) {
    if (nextTab == _selectedTab) return;
    final prevTab = _selectedTab;
    _controllers[prevTab]?.pauseRendering();
    _controllers[nextTab]?.resumeRendering();
    setState(() => _selectedTab = nextTab);
  }

  void _initAvatar(int index) async {
    final avatarID = presetAvatars[index]['id']!;
    setState(() => _loading[index] = true);

    try {
      Avatar? avatar = await AvatarManager.shared.retrieve(id: avatarID);
      avatar ??= await AvatarManager.shared.load(id: avatarID);
      setState(() {
        _avatars[index] = avatar;
        _loading[index] = false;
      });
    } catch (e) {
      setState(() => _loading[index] = false);
      if (mounted) {
        ScaffoldMessenger.of(
          context,
        ).showSnackBar(SnackBar(content: Text('Failed: $e')));
      }
    }
  }

  void _onViewCreated(int index, AvatarController controller) {
    controller.onConnectionState = (state, _) =>
        setState(() => _connStates[index] = state);
    controller.onConversationState = (state) =>
        setState(() => _convStates[index] = state);
    controller.onFrameRateInfo = (info) {
      if (!mounted) return;
      setState(() => _frameRateInfos[index] = info);
    };
    controller.setVolume(1.0);
    controller.setOpaque(false);
    controller.setFrameRateMonitorEnabled(_perfEnabled[index]);
    if (index == _selectedTab) {
      controller.resumeRendering();
    } else {
      controller.pauseRendering();
    }
    setState(() => _controllers[index] = controller);
  }

  void _togglePerf(int index) async {
    final controller = _controllers[index];
    if (controller == null) return;
    final next = !_perfEnabled[index];
    await controller.setFrameRateMonitorEnabled(next);
    setState(() {
      _perfEnabled[index] = next;
      if (!next) {
        _frameRateInfos[index] = null;
      }
    });
  }

  Widget _buildMultiPerfLine(FrameRateInfo? info) {
    if (info == null) return const SizedBox.shrink();

    Color fpsColor(double fps) {
      if (fps >= 23) return Colors.green;
      if (fps >= 15) return Colors.orange;
      return Colors.red;
    }

    return Row(
      children: [
        Text(
          'Prod ${info.productionFps.toStringAsFixed(1)}',
          style: TextStyle(fontSize: 11, color: fpsColor(info.productionFps)),
        ),
        const SizedBox(width: 8),
        Text(
          'Disp ${info.displayFps.toStringAsFixed(1)}',
          style: TextStyle(fontSize: 11, color: fpsColor(info.displayFps)),
        ),
        const SizedBox(width: 8),
        Text(
          'P95 ${info.frameP95Ms.toStringAsFixed(1)}ms',
          style: const TextStyle(fontSize: 11, color: Colors.grey),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controllers[_selectedTab];
    final isConnected = _connStates[_selectedTab] == ConnectionState.connected;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Multi Instance'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(48),
          child: Row(
            children: List.generate(
              4,
              (i) => Expanded(
                child: GestureDetector(
                  onTap: () => _switchTab(i),
                  child: Container(
                    height: 48,
                    alignment: Alignment.center,
                    decoration: BoxDecoration(
                      border: Border(
                        bottom: BorderSide(
                          color: _selectedTab == i
                              ? Theme.of(context).colorScheme.primary
                              : Colors.transparent,
                          width: 2,
                        ),
                      ),
                    ),
                    child: Text(
                      presetAvatars[i]['name']!,
                      style: TextStyle(
                        fontSize: 13,
                        fontWeight: _selectedTab == i
                            ? FontWeight.bold
                            : FontWeight.normal,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
      body: Column(
        children: [
          // All AvatarWidgets stacked, visibility controlled by Offstage
          Expanded(
            child: Stack(
              children: List.generate(4, (i) {
                final avatar = _avatars[i];
                if (avatar == null) {
                  // Show placeholder only for selected tab
                  return Offstage(
                    offstage: _selectedTab != i,
                    child: Center(
                      child: _loading[i]
                          ? const CircularProgressIndicator()
                          : Text(
                              'Tap Initialize',
                              style: TextStyle(color: Colors.grey[500]),
                            ),
                    ),
                  );
                }
                return Offstage(
                  offstage: _selectedTab != i,
                  child: AvatarWidget(
                    avatar: avatar,
                    onPlatformViewCreated: (c) => _onViewCreated(i, c),
                  ),
                );
              }),
            ),
          ),
          // Status
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
            child: Column(
              children: [
                Row(
                  children: [
                    Container(
                      width: 8,
                      height: 8,
                      decoration: BoxDecoration(
                        color:
                            _connStates[_selectedTab] ==
                                ConnectionState.connected
                            ? Colors.green
                            : _connStates[_selectedTab] ==
                                  ConnectionState.connecting
                            ? Colors.orange
                            : _connStates[_selectedTab] ==
                                  ConnectionState.failed
                            ? Colors.red
                            : Colors.grey,
                        shape: BoxShape.circle,
                      ),
                    ),
                    const SizedBox(width: 4),
                    Text(
                      _connStates[_selectedTab].name,
                      style: const TextStyle(fontSize: 12),
                    ),
                    const SizedBox(width: 16),
                    Text(
                      _convStates[_selectedTab].name,
                      style: const TextStyle(fontSize: 12, color: Colors.grey),
                    ),
                  ],
                ),
                if (_perfEnabled[_selectedTab]) ...[
                  const SizedBox(height: 4),
                  _buildMultiPerfLine(_frameRateInfos[_selectedTab]),
                ],
              ],
            ),
          ),
          // Controls
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Column(
              children: [
                Row(
                  children: [
                    Expanded(
                      child: OutlinedButton(
                        onPressed:
                            _avatars[_selectedTab] == null &&
                                !_loading[_selectedTab]
                            ? () => _initAvatar(_selectedTab)
                            : null,
                        child: const Text(
                          'Initialize',
                          style: TextStyle(fontSize: 12),
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: OutlinedButton(
                        onPressed: controller != null
                            ? () {
                                if (isConnected) {
                                  controller.close();
                                  setState(() {
                                    _connStates[_selectedTab] =
                                        ConnectionState.disconnected;
                                    _convStates[_selectedTab] =
                                        ConversationState.idle;
                                  });
                                } else {
                                  controller.start();
                                }
                              }
                            : null,
                        child: Text(
                          isConnected ? 'Stop' : 'Start',
                          style: const TextStyle(fontSize: 12),
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: OutlinedButton(
                        onPressed: isConnected
                            ? () => controller!.interrupt()
                            : null,
                        child: const Text(
                          'Interrupt',
                          style: TextStyle(fontSize: 12),
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Row(
                  children: [
                    Expanded(
                      child: OutlinedButton(
                        onPressed: controller != null
                            ? () => _togglePerf(_selectedTab)
                            : null,
                        child: Text(
                          _perfEnabled[_selectedTab] ? 'Perf ON' : 'Perf OFF',
                          style: const TextStyle(fontSize: 12),
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    const Expanded(child: SizedBox.shrink()),
                    const SizedBox(width: 8),
                    const Expanded(child: SizedBox.shrink()),
                  ],
                ),
              ],
            ),
          ),
          // Audio files
          SizedBox(
            height: 120,
            child: ListView.builder(
              itemCount: audioFiles.length,
              itemBuilder: (context, i) {
                final file = audioFiles[i];
                return ListTile(
                  leading: const Icon(Icons.graphic_eq, size: 18),
                  title: Text(file, style: const TextStyle(fontSize: 13)),
                  dense: true,
                  enabled: isConnected,
                  onTap: () => _sendAudio(_selectedTab, file),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  void _sendAudio(int index, String filename) async {
    final controller = _controllers[index];
    if (controller == null) return;
    controller.interrupt();
    try {
      final data = await rootBundle.load('assets/$filename');
      final audioData = data.buffer.asUint8List();
      final chunkSize = widget.config.sampleRate * 2;
      int offset = 0;
      Timer.periodic(const Duration(milliseconds: 100), (timer) {
        if (offset >= audioData.length) {
          timer.cancel();
          return;
        }
        final end = (offset + chunkSize).clamp(0, audioData.length);
        final chunk = audioData.sublist(offset, end);
        controller.send(
          Uint8List.fromList(chunk),
          end: end >= audioData.length,
        );
        offset = end;
      });
    } catch (e) {
      debugPrint('Audio send error: $e');
    }
  }
}
1
likes
0
points
688
downloads

Publisher

verified publisherspatialwalk.ai

Weekly Downloads

A Flutter plugin for AvatarKit, which provides high-performance avatar rendering with real-time and audio-driven capabilities.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on avatar_kit

Packages that implement avatar_kit