yumi-flutter/lib/services/music/room_music_manager.dart
2026-06-15 16:21:05 +08:00

590 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:audio_session/audio_session.dart';
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<SCMusicMode> _playlist = <SCMusicMode>[];
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<SCMusicMode> 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?.setRoomMusicSessionExitListener(null);
_rtcProvider = provider;
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
provider.setRoomMusicSessionExitListener(stopForRoomExit);
_syncPublishingState(force: true);
}
Future<void> reload() async {
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> applyFolderSelection({
required SCMusicFolderMode folder,
required Set<String> selectedIds,
}) async {
final currentById = {for (final item in _playlist) item.id: item};
final folderIds = folder.songs.map((item) => item.id).toSet();
final next = <SCMusicMode>[
..._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<void> 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<void> 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<void> 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<void> pause() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
await _rtcProvider?.pauseRoomMusicAudioMixing();
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
_syncPublishingState();
notifyListeners();
}
Future<void> 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<void> 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<void> stopForRoomExit() async {
await stop(clearCurrent: true);
}
Future<void> previous() async {
if (_playlist.isEmpty) {
return;
}
final index = currentIndex;
final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
await play(_playlist[targetIndex]);
}
Future<void> 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<void> seek(int positionMs) async {
final safePosition = positionMs.clamp(0, durationMs).toInt();
_positionMs = safePosition;
await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
notifyListeners();
}
Future<void> 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<void> 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<void> _savePlaylist(List<SCMusicMode> songs) async {
await _repository.saveSongs(songs);
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> _startCurrent({required int startPositionMs}) async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || _isStarting) {
return;
}
_isStarting = true;
try {
await _configureIosAudioSessionIfNeeded();
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<void> _waitForStartInterval() async {
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _lastStartAtMs;
if (elapsed < 550) {
await Future<void>.delayed(Duration(milliseconds: 550 - elapsed));
}
}
Future<void> _configureIosAudioSessionIfNeeded() async {
if (!Platform.isIOS) {
return;
}
try {
final session = await AudioSession.instance;
await session.configure(
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.mixWithOthers |
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.allowBluetoothA2dp,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
),
);
} catch (_) {
// TRTC can still manage the iOS audio session if this best-effort setup fails.
}
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
unawaited(_refreshPosition());
});
}
void _stopPositionTimer() {
_positionTimer?.cancel();
_positionTimer = null;
}
Future<void> _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);
_rtcProvider?.setRoomMusicSessionExitListener(null);
super.dispose();
}
}