590 lines
17 KiB
Dart
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();
|
|
}
|
|
}
|