import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/room_rtc_types.dart'; import 'package:yumi/services/music/room_music_repository.dart'; import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart'; import 'package:yumi/shared/data_sources/models/sc_music_mode.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; enum RoomMusicPlayMode { list, shuffle, single } class RoomMusicManager extends ChangeNotifier { static const Duration _publishingStateHeartbeatInterval = Duration( seconds: 5, ); RoomMusicManager({RoomMusicRepository? repository}) : _repository = repository ?? RoomMusicRepository() { _playlist = _repository.loadSongs(); } final RoomMusicRepository _repository; final Random _random = Random(); RtcProvider? _rtcProvider; Timer? _positionTimer; Timer? _publishingStateHeartbeatTimer; List _playlist = []; SCMusicMode? _current; RoomMusicPlayMode _playMode = RoomMusicPlayMode.list; int _volume = 70; int _positionMs = 0; int _durationMs = 0; int _lastStartAtMs = 0; int _ignoreStoppedUntilMs = 0; bool _isPlaying = false; bool _isPaused = false; bool _isStarting = false; bool _isStoppingByUser = false; bool _isRoutingSwitching = false; bool _lastPublishedRoomState = false; bool _lastLoopback = true; bool _roomPlayerVisible = false; List get playlist => List.unmodifiable(_playlist); SCMusicMode? get current => _current; RoomMusicPlayMode get playMode => _playMode; int get volume => _volume; int get positionMs => _positionMs; int get durationMs => _durationMs > 0 ? _durationMs : (_current?.durationMs ?? 0); bool get isPlaying => _isPlaying; bool get isPaused => _isPaused; bool get hasCurrent => _current != null; bool get roomPlayerVisible => _roomPlayerVisible; bool get isPublishingToRoom => _isPlaying && !_isPaused && !_lastLoopback && (_rtcProvider?.isOnMai() ?? false); int get currentIndex { final currentId = _current?.id; if (currentId == null) { return -1; } return _playlist.indexWhere((item) => item.id == currentId); } void attachRtcProvider(RtcProvider provider) { if (_rtcProvider == provider) { return; } _rtcProvider?.setRoomMusicMixingStateListener(null); _rtcProvider?.setRoomMusicMicRouteChangedListener(null); _rtcProvider = provider; provider.setRoomMusicMixingStateListener(_handleMixingStateChanged); provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged); _syncPublishingState(force: true); } Future reload() async { _playlist = _repository.loadSongs(); _restoreCurrentAfterPlaylistChange(); notifyListeners(); } Future applyFolderSelection({ required SCMusicFolderMode folder, required Set selectedIds, }) async { final currentById = {for (final item in _playlist) item.id: item}; final folderIds = folder.songs.map((item) => item.id).toSet(); final next = [ ..._playlist.where((item) => !folderIds.contains(item.id)), ]; final now = DateTime.now().millisecondsSinceEpoch; for (final song in folder.songs) { if (!selectedIds.contains(song.id)) { continue; } final existing = currentById[song.id]; next.add( (existing ?? song).copyWith( addedAt: existing?.addedAt == null || existing!.addedAt <= 0 ? now : existing.addedAt, ), ); } await _savePlaylist(next); } Future deleteMusic(SCMusicMode item) async { final deletingCurrent = _current?.id == item.id; if (deletingCurrent) { await stop(clearCurrent: true); } await _savePlaylist(_playlist.where((song) => song.id != item.id).toList()); } Future reorder(int oldIndex, int newIndex) async { if (oldIndex < 0 || oldIndex >= _playlist.length) { return; } final next = [..._playlist]; if (newIndex > oldIndex) { newIndex -= 1; } final item = next.removeAt(oldIndex); next.insert(newIndex.clamp(0, next.length).toInt(), item); await _savePlaylist(next); } Future play( SCMusicMode item, { int startPositionMs = 0, bool showRoomPlayer = true, }) async { if (item.playPath.isEmpty) { SCTts.show("Music file not found"); return; } if (!item.playPath.startsWith("content://") && !File(item.localPath).existsSync()) { SCTts.show("Music file not found"); await deleteMusic(item); return; } _current = item; _durationMs = item.durationMs; _positionMs = startPositionMs; _isPaused = false; if (showRoomPlayer) { _roomPlayerVisible = true; } await _startCurrent(startPositionMs: startPositionMs); } Future pause() async { if (!_isPlaying || _rtcProvider == null) { return; } await _rtcProvider?.pauseRoomMusicAudioMixing(); _isPlaying = false; _isPaused = true; _stopPositionTimer(); _syncPublishingState(); notifyListeners(); } Future resume() async { if (_current == null || _rtcProvider == null) { return; } if (_isPaused) { final nextLoopback = !_rtcProvider!.isOnMai(); if (nextLoopback != _lastLoopback) { final wasRoomPlayerVisible = _roomPlayerVisible; _ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900; _isRoutingSwitching = true; try { await _rtcProvider?.stopRoomMusicAudioMixing(); await _startCurrent(startPositionMs: _positionMs); _roomPlayerVisible = wasRoomPlayerVisible; } finally { _isRoutingSwitching = false; } return; } await _rtcProvider?.resumeRoomMusicAudioMixing(); _isPlaying = true; _isPaused = false; _startPositionTimer(); _syncPublishingState(); notifyListeners(); return; } await _startCurrent(startPositionMs: _positionMs); } Future stop({bool clearCurrent = false}) async { _isStoppingByUser = true; try { await _rtcProvider?.stopRoomMusicAudioMixing(); } catch (_) { // Ignore stop failures; local state must still be reset. } finally { _isStoppingByUser = false; _stopPositionTimer(); _isPlaying = false; _isPaused = false; _positionMs = 0; if (clearCurrent) { _current = null; _durationMs = 0; _roomPlayerVisible = false; } _syncPublishingState(force: clearCurrent); notifyListeners(); } } Future stopForRoomExit() async { await stop(clearCurrent: true); } Future previous() async { if (_playlist.isEmpty) { return; } final index = currentIndex; final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1; await play(_playlist[targetIndex]); } Future next({bool fromAutoComplete = false}) async { if (_playlist.isEmpty) { await stop(clearCurrent: true); return; } if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) { await play(_current ?? _playlist.first, showRoomPlayer: false); return; } int targetIndex = 0; final index = currentIndex; if (_playMode == RoomMusicPlayMode.shuffle) { if (_playlist.length == 1) { targetIndex = 0; } else { do { targetIndex = _random.nextInt(_playlist.length); } while (targetIndex == index); } } else { targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length; } await play(_playlist[targetIndex], showRoomPlayer: !fromAutoComplete); } Future seek(int positionMs) async { final safePosition = positionMs.clamp(0, durationMs).toInt(); _positionMs = safePosition; await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition); notifyListeners(); } Future setVolume(int volume) async { _volume = volume.clamp(0, 100).toInt(); await _rtcProvider?.setRoomMusicAudioMixingVolume(_volume); notifyListeners(); } String switchPlayMode() { switch (_playMode) { case RoomMusicPlayMode.list: _playMode = RoomMusicPlayMode.shuffle; notifyListeners(); return "随机播放"; case RoomMusicPlayMode.shuffle: _playMode = RoomMusicPlayMode.single; notifyListeners(); return "单曲循环播放"; case RoomMusicPlayMode.single: _playMode = RoomMusicPlayMode.list; notifyListeners(); return "列表顺序播放"; } } void toggleRoomPlayerVisible() { if (_current == null) { return; } _roomPlayerVisible = !_roomPlayerVisible; notifyListeners(); } void hideRoomPlayer() { if (!_roomPlayerVisible) { return; } _roomPlayerVisible = false; notifyListeners(); } Future syncPlaybackRouting() async { final current = _current; final rtcProvider = _rtcProvider; if (current == null || rtcProvider == null || !_isPlaying) { return; } final nextLoopback = !rtcProvider.isOnMai(); if (nextLoopback == _lastLoopback) { return; } final position = await rtcProvider .getRoomMusicAudioMixingPosition() .catchError((_) => _positionMs); _ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900; final wasRoomPlayerVisible = _roomPlayerVisible; _isRoutingSwitching = true; try { await rtcProvider.stopRoomMusicAudioMixing(); await _startCurrent(startPositionMs: position); _roomPlayerVisible = wasRoomPlayerVisible; } catch (_) { _isPlaying = false; _isPaused = false; _syncPublishingState(); notifyListeners(); } finally { _isRoutingSwitching = false; } } Future _savePlaylist(List songs) async { await _repository.saveSongs(songs); _playlist = _repository.loadSongs(); _restoreCurrentAfterPlaylistChange(); notifyListeners(); } Future _startCurrent({required int startPositionMs}) async { final current = _current; final rtcProvider = _rtcProvider; if (current == null || rtcProvider == null || _isStarting) { return; } _isStarting = true; try { await _waitForStartInterval(); _lastLoopback = !rtcProvider.isOnMai(); await rtcProvider.startRoomMusicAudioMixing( filePath: current.playPath, loopback: _lastLoopback, cycle: 1, startPos: startPositionMs, ); _lastStartAtMs = DateTime.now().millisecondsSinceEpoch; await rtcProvider.setRoomMusicAudioMixingVolume(_volume); _positionMs = startPositionMs; _durationMs = current.durationMs > 0 ? current.durationMs : await rtcProvider.getRoomMusicAudioMixingDuration(); _isPlaying = true; _isPaused = false; _startPositionTimer(); _syncPublishingState(); notifyListeners(); } catch (_) { _isPlaying = false; _isPaused = false; _stopPositionTimer(); _syncPublishingState(); SCTts.show("Music playback failed"); notifyListeners(); } finally { _isStarting = false; } } Future _waitForStartInterval() async { final now = DateTime.now().millisecondsSinceEpoch; final elapsed = now - _lastStartAtMs; if (elapsed < 550) { await Future.delayed(Duration(milliseconds: 550 - elapsed)); } } void _startPositionTimer() { _positionTimer?.cancel(); _positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) { unawaited(_refreshPosition()); }); } void _stopPositionTimer() { _positionTimer?.cancel(); _positionTimer = null; } Future _refreshPosition() async { if (!_isPlaying || _rtcProvider == null) { return; } try { final position = await _rtcProvider!.getRoomMusicAudioMixingPosition(); final duration = await _rtcProvider!.getRoomMusicAudioMixingDuration(); if (position >= 0) { _positionMs = position; } if (duration > 0) { _durationMs = duration; } notifyListeners(); } catch (_) { // Position polling is best-effort only. } } void _handleMixingStateChanged( RoomRtcAudioMixingState state, RoomRtcAudioMixingReason reason, ) { switch (state) { case RoomRtcAudioMixingState.playing: _isPlaying = true; _isPaused = false; _startPositionTimer(); _syncPublishingState(); notifyListeners(); break; case RoomRtcAudioMixingState.paused: _isPlaying = false; _isPaused = true; _stopPositionTimer(); _syncPublishingState(); notifyListeners(); break; case RoomRtcAudioMixingState.stopped: if (_isRoutingSwitching) { return; } if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) { return; } _stopPositionTimer(); if (reason == RoomRtcAudioMixingReason.allLoopsCompleted) { unawaited(next(fromAutoComplete: true)); return; } if (!_isStoppingByUser) { _isPlaying = false; _isPaused = false; _positionMs = 0; _syncPublishingState(); notifyListeners(); } break; case RoomRtcAudioMixingState.failed: _stopPositionTimer(); _isPlaying = false; _isPaused = false; _syncPublishingState(); SCTts.show("Music playback failed"); notifyListeners(); break; } } void _handleMicRouteChanged(bool isOnMic) { unawaited(syncPlaybackRouting()); _syncPublishingState(); } void _syncPublishingState({bool force = false}) { final rtcProvider = _rtcProvider; if (rtcProvider == null) { _updatePublishingStateHeartbeat(false); return; } final publishing = isPublishingToRoom; _updatePublishingStateHeartbeat(publishing); if (!force && publishing == _lastPublishedRoomState) { return; } _lastPublishedRoomState = publishing; rtcProvider.publishCurrentUserRoomMusicState(publishing); } void _updatePublishingStateHeartbeat(bool publishing) { if (!publishing) { _publishingStateHeartbeatTimer?.cancel(); _publishingStateHeartbeatTimer = null; return; } if (_publishingStateHeartbeatTimer != null) { return; } _publishingStateHeartbeatTimer = Timer.periodic( _publishingStateHeartbeatInterval, (_) { if (!isPublishingToRoom) { _publishingStateHeartbeatTimer?.cancel(); _publishingStateHeartbeatTimer = null; _syncPublishingState(force: true); return; } _rtcProvider?.publishCurrentUserRoomMusicState(true); }, ); } void _restoreCurrentAfterPlaylistChange() { final currentId = _current?.id; if (currentId == null) { return; } final index = _playlist.indexWhere((item) => item.id == currentId); if (index >= 0) { _current = _playlist[index]; return; } _current = null; _isPlaying = false; _isPaused = false; _syncPublishingState(force: true); _roomPlayerVisible = false; _positionMs = 0; _durationMs = 0; _stopPositionTimer(); } @override void dispose() { _positionTimer?.cancel(); _publishingStateHeartbeatTimer?.cancel(); _rtcProvider?.setRoomMusicMixingStateListener(null); _rtcProvider?.setRoomMusicMicRouteChangedListener(null); super.dispose(); } }