package com.example.playback import android.content.Context import android.net.Uri import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.example.data.model.MediaItemData import com.example.data.model.RepeatMode import com.example.data.model.VideoScaleMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @UnstableApi object PlaybackManager { private var exoPlayer: ExoPlayer? = null private val scope = CoroutineScope(Dispatchers.Main + Job()) private var positionUpdateJob: Job? = null private val _currentMediaItem = MutableStateFlow(null) val currentMediaItem: StateFlow = _currentMediaItem.asStateFlow() private val _isPlaying = MutableStateFlow(false) val isPlaying: StateFlow = _isPlaying.asStateFlow() private val _currentPositionMs = MutableStateFlow(0L) val currentPositionMs: StateFlow = _currentPositionMs.asStateFlow() private val _durationMs = MutableStateFlow(0L) val durationMs: StateFlow = _durationMs.asStateFlow() private val _playbackState = MutableStateFlow(Player.STATE_IDLE) val playbackState: StateFlow = _playbackState.asStateFlow() private val _queue = MutableStateFlow>(emptyList()) val queue: StateFlow> = _queue.asStateFlow() private val _queueIndex = MutableStateFlow(-1) val queueIndex: StateFlow = _queueIndex.asStateFlow() private val _videoScaleMode = MutableStateFlow(VideoScaleMode.FIT) val videoScaleMode: StateFlow = _videoScaleMode.asStateFlow() private val _videoDimensions = MutableStateFlow(Pair(0, 0)) val videoDimensions: StateFlow> = _videoDimensions.asStateFlow() private val _playbackSpeed = MutableStateFlow(1.0f) val playbackSpeed: StateFlow = _playbackSpeed.asStateFlow() private val _repeatMode = MutableStateFlow(RepeatMode.OFF) val repeatMode: StateFlow = _repeatMode.asStateFlow() private val _isShuffleEnabled = MutableStateFlow(false) val isShuffleEnabled: StateFlow = _isShuffleEnabled.asStateFlow() private val _isAudioControlsVisible = MutableStateFlow(false) val isAudioControlsVisible: StateFlow = _isAudioControlsVisible.asStateFlow() var onSaveHistoryCallback: ((item: MediaItemData, positionMs: Long, completed: Boolean) -> Unit)? = null fun getOrCreatePlayer(context: Context): ExoPlayer { if (exoPlayer == null) { val audioAttributes = AudioAttributes.Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setUsage(C.USAGE_MEDIA) .build() val player = ExoPlayer.Builder(context.applicationContext) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) .build() player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { _isPlaying.value = isPlaying if (isPlaying) { startPositionUpdates() } else { stopPositionUpdates() saveCurrentPosition() } } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { val state = exoPlayer?.playbackState ?: Player.STATE_IDLE _isPlaying.value = playWhenReady && (state == Player.STATE_READY || state == Player.STATE_BUFFERING) if (!playWhenReady) { stopPositionUpdates() saveCurrentPosition() } } override fun onPlaybackStateChanged(state: Int) { _playbackState.value = state if (state == Player.STATE_READY) { _durationMs.value = exoPlayer?.duration?.coerceAtLeast(0L) ?: 0L } else if (state == Player.STATE_ENDED) { _isPlaying.value = false handleTrackEnded() } } override fun onVideoSizeChanged(videoSize: VideoSize) { _videoDimensions.value = Pair(videoSize.width, videoSize.height) } }) exoPlayer = player } return exoPlayer!! } fun setQueue(items: List, startIndex: Int, autoPlay: Boolean = true, startPosMs: Long = 0L) { if (items.isEmpty()) return _queue.value = items val index = startIndex.coerceIn(0, items.lastIndex) _queueIndex.value = index playItemInternal(items[index], startPosMs, autoPlay) } fun playMediaItem(item: MediaItemData, startPositionMs: Long = 0L, autoPlay: Boolean = true) { val existingIndex = _queue.value.indexOfFirst { it.id == item.id } if (existingIndex >= 0) { _queueIndex.value = existingIndex } else { val currentList = _queue.value.toMutableList() currentList.add(item) _queue.value = currentList _queueIndex.value = currentList.lastIndex } playItemInternal(item, startPositionMs, autoPlay) } private fun playItemInternal(item: MediaItemData, startPositionMs: Long, autoPlay: Boolean) { val player = exoPlayer ?: return saveCurrentPosition() _currentMediaItem.value = item _videoDimensions.value = Pair(item.width, item.height) val mediaMetadata = MediaMetadata.Builder() .setTitle(item.title) .setArtist(item.artist) .setAlbumTitle(item.album) .apply { item.albumArtUri?.let { setArtworkUri(Uri.parse(it)) } } .build() val mediaItem = MediaItem.Builder() .setUri(item.contentUri) .setMediaMetadata(mediaMetadata) .build() player.setMediaItem(mediaItem) player.prepare() if (startPositionMs > 0) { player.seekTo(startPositionMs) _currentPositionMs.value = startPositionMs } else { _currentPositionMs.value = 0L } player.playbackParameters = PlaybackParameters(_playbackSpeed.value) player.playWhenReady = autoPlay } fun togglePlayPause() { val player = exoPlayer ?: return if (player.playWhenReady) { pause() } else { resume() } } fun pause() { val player = exoPlayer ?: return player.playWhenReady = false player.pause() _isPlaying.value = false stopPositionUpdates() saveCurrentPosition() } fun resume() { val player = exoPlayer ?: return if (player.playbackState == Player.STATE_ENDED) { player.seekTo(0) } player.playWhenReady = true player.play() _isPlaying.value = true startPositionUpdates() } fun seekTo(positionMs: Long) { exoPlayer?.seekTo(positionMs) _currentPositionMs.value = positionMs } fun seekBy(deltaMs: Long) { val current = exoPlayer?.currentPosition ?: return val dur = exoPlayer?.duration ?: return val target = (current + deltaMs).coerceIn(0L, dur.coerceAtLeast(0L)) seekTo(target) } fun playNext() { val list = _queue.value if (list.isEmpty()) return val nextIndex = if (_isShuffleEnabled.value) { list.indices.random() } else { (_queueIndex.value + 1).coerceAtMost(list.lastIndex) } if (nextIndex != _queueIndex.value || _isShuffleEnabled.value) { _queueIndex.value = nextIndex playItemInternal(list[nextIndex], 0L, true) } } fun playPrevious() { val player = exoPlayer if (player != null && player.currentPosition > 3000) { seekTo(0) return } val list = _queue.value if (list.isEmpty()) return val prevIndex = (_queueIndex.value - 1).coerceAtLeast(0) _queueIndex.value = prevIndex playItemInternal(list[prevIndex], 0L, true) } fun addToQueue(item: MediaItemData) { val list = _queue.value.toMutableList() if (!list.any { it.id == item.id }) { list.add(item) _queue.value = list if (_queueIndex.value == -1) { _queueIndex.value = 0 playItemInternal(item, 0L, false) } } } fun playNextInQueue(item: MediaItemData) { val list = _queue.value.toMutableList() list.removeAll { it.id == item.id } val insertIdx = (_queueIndex.value + 1).coerceIn(0, list.size) list.add(insertIdx, item) _queue.value = list } fun removeFromQueue(index: Int) { val list = _queue.value.toMutableList() if (index in list.indices) { val isCurrent = index == _queueIndex.value list.removeAt(index) _queue.value = list if (isCurrent) { if (list.isNotEmpty()) { val nextIndex = index.coerceIn(0, list.lastIndex) _queueIndex.value = nextIndex playItemInternal(list[nextIndex], 0L, true) } else { _queueIndex.value = -1 _currentMediaItem.value = null exoPlayer?.stop() } } else if (index < _queueIndex.value) { _queueIndex.value = _queueIndex.value - 1 } } } fun reorderQueue(fromIndex: Int, toIndex: Int) { val list = _queue.value.toMutableList() if (fromIndex in list.indices && toIndex in list.indices) { val item = list.removeAt(fromIndex) list.add(toIndex, item) _queue.value = list val currentItem = _currentMediaItem.value if (currentItem != null) { _queueIndex.value = list.indexOfFirst { it.id == currentItem.id } } } } fun clearQueue() { _queue.value = emptyList() _queueIndex.value = -1 _currentMediaItem.value = null exoPlayer?.stop() } fun setPlaybackSpeed(speed: Float) { _playbackSpeed.value = speed exoPlayer?.playbackParameters = PlaybackParameters(speed) } fun setVideoScaleMode(mode: VideoScaleMode) { _videoScaleMode.value = mode } fun toggleVideoScaleMode() { _videoScaleMode.value = if (_videoScaleMode.value == VideoScaleMode.FIT) VideoScaleMode.FILL else VideoScaleMode.FIT } fun setRepeatMode(mode: RepeatMode) { _repeatMode.value = mode } fun toggleShuffle() { _isShuffleEnabled.value = !_isShuffleEnabled.value } fun toggleAudioControlsVisibility() { _isAudioControlsVisible.value = !_isAudioControlsVisible.value } fun setAudioControlsVisible(visible: Boolean) { _isAudioControlsVisible.value = visible } private fun handleTrackEnded() { val current = _currentMediaItem.value ?: return onSaveHistoryCallback?.invoke(current, _durationMs.value, true) when (_repeatMode.value) { RepeatMode.ONE -> { seekTo(0) resume() } RepeatMode.ALL -> { val list = _queue.value if (list.isNotEmpty()) { val nextIndex = (_queueIndex.value + 1) % list.size _queueIndex.value = nextIndex playItemInternal(list[nextIndex], 0L, true) } } RepeatMode.OFF -> { val list = _queue.value if (_queueIndex.value < list.lastIndex) { playNext() } } } } private fun startPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = scope.launch { var counter = 0 while (isActive) { exoPlayer?.let { player -> if (player.isPlaying) { _currentPositionMs.value = player.currentPosition _durationMs.value = player.duration.coerceAtLeast(0L) counter++ if (counter % 10 == 0) { saveCurrentPosition() } } } delay(300) } } } private fun stopPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = null } fun saveCurrentPosition() { val item = _currentMediaItem.value ?: return val pos = _currentPositionMs.value val isFinished = _durationMs.value > 0 && pos >= (_durationMs.value - 2000) onSaveHistoryCallback?.invoke(item, pos.coerceAtLeast(0L), isFinished) } fun release() { stopPositionUpdates() exoPlayer?.release() exoPlayer = null } }