diff --git a/app/src/main/java/com/happyseal/zenplayer/core/navigation/NavGraph.kt b/app/src/main/java/com/happyseal/zenplayer/core/navigation/NavGraph.kt index 9c33f7e8..0f42cb49 100644 --- a/app/src/main/java/com/happyseal/zenplayer/core/navigation/NavGraph.kt +++ b/app/src/main/java/com/happyseal/zenplayer/core/navigation/NavGraph.kt @@ -75,7 +75,7 @@ fun NavGraph( val miniPlayerViewModel = hiltViewModel() LaunchedEffect(playlistId) { - viewModel.selectPlaylist(playlistId) + viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId)) } val uiState by viewModel.detailUiState.collectAsStateWithLifecycle() @@ -89,11 +89,13 @@ fun NavGraph( uiEvent = viewModel.uiEvent, onAction = { action -> when (action) { - is PlaylistDetailAction.PlayPlaylist -> viewModel.playPlaylist(action.playlistId, action.startIndex) - is PlaylistDetailAction.RemoveTrack -> viewModel.removeTrackFromPlaylist(action.trackRowId) - is PlaylistDetailAction.MiniPlayer -> miniPlayerViewModel.onAction(action.action) + is PlaylistDetailAction.SelectPlaylist, + is PlaylistDetailAction.PlayPlaylist, + is PlaylistDetailAction.RemoveTrack, + -> viewModel.onAction(action) } }, + onMiniPlayerAction = { miniPlayerViewModel.onAction(it) }, onNavigateBack = { navController.navigateUp() }, onNavigateToPlayer = { navController.navigate(Screen.Zen.route) { diff --git a/app/src/main/java/com/happyseal/zenplayer/core/service/ZenPlayerServiceBinderExtensions.kt b/app/src/main/java/com/happyseal/zenplayer/core/service/ZenPlayerServiceBinderExtensions.kt new file mode 100644 index 00000000..69fb3b3a --- /dev/null +++ b/app/src/main/java/com/happyseal/zenplayer/core/service/ZenPlayerServiceBinderExtensions.kt @@ -0,0 +1,47 @@ +package com.happyseal.zenplayer.core.service + +import com.happyseal.zenplayer.core.ui.component.MiniPlayerUiState +import com.happyseal.zenplayer.core.util.WhileSubscribed5s +import com.happyseal.zenplayer.core.util.whenNonNull +import com.happyseal.zenplayer.features.player.domain.PlaybackState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +/** + * binderFlow로부터 MiniPlayerUiState를 생성하는 공통 확장 함수. + * MiniPlayerViewModel과 ZenMusicSelectViewModel 등 MiniPlayer 상태가 필요한 + * 모든 ViewModel에서 재사용한다. + */ +fun ZenPlayerServiceBinder.miniPlayerUiState(scope: CoroutineScope): StateFlow = binderFlow + .whenNonNull(MiniPlayerUiState()) { binder -> + combine( + binder.playQueue, + binder.playbackState, + ) { playQueue, playbackState -> + MiniPlayerUiState( + currentTrackTitle = playQueue?.currentTrack?.title, + currentTrackArtist = playQueue?.currentTrack?.artist, + albumArtUri = playQueue?.currentTrack?.albumArtUri, + trackId = playQueue?.currentTrack?.id ?: 0L, + isPlaying = playbackState == PlaybackState.PLAYING, + ) + } + }.stateIn(scope, SharingStarted.WhileSubscribed5s, MiniPlayerUiState()) + +/** + * binderFlow로부터 재생 진행률(0f~1f)을 생성하는 공통 확장 함수. + * MiniPlayerViewModel과 ZenMusicSelectViewModel 등 progress가 필요한 + * 모든 ViewModel에서 재사용한다. + */ +fun ZenPlayerServiceBinder.miniPlayerProgress(scope: CoroutineScope): StateFlow = binderFlow + .whenNonNull(0f) { binder -> + combine( + binder.currentPosition, + binder.duration, + ) { position, duration -> + if (duration > 0) (position.toFloat() / duration).coerceIn(0f, 1f) else 0f + } + }.stateIn(scope, SharingStarted.WhileSubscribed5s, 0f) diff --git a/app/src/main/java/com/happyseal/zenplayer/features/music/ui/AudioPermissionHandler.kt b/app/src/main/java/com/happyseal/zenplayer/features/music/ui/AudioPermissionHandler.kt index 1cb86f5e..ab50ac3c 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/music/ui/AudioPermissionHandler.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/music/ui/AudioPermissionHandler.kt @@ -11,6 +11,8 @@ import javax.inject.Inject class AudioPermissionHandler @Inject constructor() { + // WhileSubscribed5s 미사용: 권한 상태는 ViewModel 생명주기 동안 유지되어야 하므로 + // stateIn() 대신 MutableStateFlow.asStateFlow()로 항상 활성 상태를 유지 private val _status = MutableStateFlow(AudioPermissionStatus.UNDETERMINED) val status: StateFlow = _status.asStateFlow() diff --git a/app/src/main/java/com/happyseal/zenplayer/features/player/data/ZenAudioController.kt b/app/src/main/java/com/happyseal/zenplayer/features/player/data/ZenAudioController.kt index 0ee00c38..71934c50 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/player/data/ZenAudioController.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/player/data/ZenAudioController.kt @@ -248,6 +248,8 @@ class ZenAudioController( } companion object { + // TimerEngine.UPDATE_INTERVAL_MS와 값이 같지만 의도가 다름. + // 이 값은 재생 위치(currentPosition) 추적 갱신 주기이며, 타이머 UI와는 독립적으로 조정될 수 있음. private const val POSITION_UPDATE_INTERVAL_MS = 100L private const val DUCK_VOLUME = 0.3f } diff --git a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailAction.kt b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailAction.kt index a566e59c..64cdd374 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailAction.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailAction.kt @@ -1,11 +1,9 @@ package com.happyseal.zenplayer.features.playlist.ui -import com.happyseal.zenplayer.core.model.MiniPlayerAction - sealed interface PlaylistDetailAction { + data class SelectPlaylist(val playlistId: Long) : PlaylistDetailAction + data class PlayPlaylist(val playlistId: Long, val startIndex: Int = 0) : PlaylistDetailAction data class RemoveTrack(val trackRowId: Long) : PlaylistDetailAction - - data class MiniPlayer(val action: MiniPlayerAction) : PlaylistDetailAction } diff --git a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt index 394a5cd2..e873aea1 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt @@ -71,6 +71,7 @@ fun PlaylistDetailScreen( miniPlayerProgress: Float, uiEvent: Flow, onAction: (PlaylistDetailAction) -> Unit, + onMiniPlayerAction: (MiniPlayerAction) -> Unit, onNavigateBack: () -> Unit, onNavigateToPlayer: () -> Unit, modifier: Modifier = Modifier, @@ -194,9 +195,9 @@ fun PlaylistDetailScreen( MiniPlayerBar( uiState = miniPlayerUiState, progressProvider = { miniPlayerProgress }, - onTogglePlayPause = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.TogglePlayPause)) }, - onSkipNext = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipNext)) }, - onSkipPrevious = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipPrevious)) }, + onTogglePlayPause = { onMiniPlayerAction(MiniPlayerAction.TogglePlayPause) }, + onSkipNext = { onMiniPlayerAction(MiniPlayerAction.SkipNext) }, + onSkipPrevious = { onMiniPlayerAction(MiniPlayerAction.SkipPrevious) }, ) } } diff --git a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModel.kt b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModel.kt index 486a1723..baa86c1d 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModel.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModel.kt @@ -23,6 +23,8 @@ constructor( private val playlistRepository: PlaylistRepository, serviceBinder: ZenPlayerServiceBinder, ) : ViewModel() { + // WhileSubscribed5s 미사용: 외부 Flow를 구독하지 않고 viewModelScope에서 + // 직접 값을 push하는 구조이므로 MutableStateFlow.asStateFlow()가 적합 private val _detailUiState = MutableStateFlow(PlaylistDetailUiState.Loading) val detailUiState: StateFlow = _detailUiState.asStateFlow() @@ -33,7 +35,15 @@ constructor( private var trackCollectionJob: Job? = null private val binder = serviceBinder.binderFlow - fun selectPlaylist(playlistId: Long) { + fun onAction(action: PlaylistDetailAction) { + when (action) { + is PlaylistDetailAction.SelectPlaylist -> selectPlaylist(action.playlistId) + is PlaylistDetailAction.PlayPlaylist -> playPlaylist(action.playlistId, action.startIndex) + is PlaylistDetailAction.RemoveTrack -> removeTrackFromPlaylist(action.trackRowId) + } + } + + private fun selectPlaylist(playlistId: Long) { require(playlistId > 0) { "유효하지 않은 playlistId: $playlistId" } trackCollectionJob?.cancel() _detailUiState.value = PlaylistDetailUiState.Loading @@ -58,7 +68,7 @@ constructor( } } - fun removeTrackFromPlaylist(trackRowId: Long) { + private fun removeTrackFromPlaylist(trackRowId: Long) { require(trackRowId > 0) { "유효하지 않은 trackRowId: $trackRowId" } viewModelScope.launch { playlistRepository @@ -70,7 +80,7 @@ constructor( } } - fun playPlaylist( + private fun playPlaylist( playlistId: Long, startIndex: Int = 0, ) { diff --git a/app/src/main/java/com/happyseal/zenplayer/features/timer/domain/TimerEngine.kt b/app/src/main/java/com/happyseal/zenplayer/features/timer/domain/TimerEngine.kt index 9883778b..57eb9b7d 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/timer/domain/TimerEngine.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/timer/domain/TimerEngine.kt @@ -379,6 +379,8 @@ class TimerEngine( } companion object { + // ZenAudioController.POSITION_UPDATE_INTERVAL_MS와 값이 같지만 의도가 다름. + // 이 값은 타이머 진행률 UI 갱신 주기이며, 오디오 재생 위치 추적과는 독립적으로 조정될 수 있음. internal const val UPDATE_INTERVAL_MS = 100L } } diff --git a/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/MiniPlayerViewModel.kt b/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/MiniPlayerViewModel.kt index ce854458..1002d71d 100644 --- a/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/MiniPlayerViewModel.kt +++ b/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/MiniPlayerViewModel.kt @@ -4,63 +4,26 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.happyseal.zenplayer.core.model.MiniPlayerAction import com.happyseal.zenplayer.core.service.ZenPlayerServiceBinder +import com.happyseal.zenplayer.core.service.miniPlayerProgress +import com.happyseal.zenplayer.core.service.miniPlayerUiState import com.happyseal.zenplayer.core.ui.component.MiniPlayerUiState -import com.happyseal.zenplayer.core.util.WhileSubscribed5s -import com.happyseal.zenplayer.core.util.whenNonNull import com.happyseal.zenplayer.features.player.domain.PlaybackState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class MiniPlayerViewModel @Inject constructor( - serviceBinder: ZenPlayerServiceBinder, + private val serviceBinder: ZenPlayerServiceBinder, ) : ViewModel() { - private val _binder = serviceBinder.binderFlow + val uiState: StateFlow = serviceBinder.miniPlayerUiState(viewModelScope) - val uiState: StateFlow = - _binder - .whenNonNull(MiniPlayerUiState()) { binder -> - combine( - binder.playQueue, - binder.playbackState, - ) { playQueue, playbackState -> - MiniPlayerUiState( - currentTrackTitle = playQueue?.currentTrack?.title, - currentTrackArtist = playQueue?.currentTrack?.artist, - albumArtUri = playQueue?.currentTrack?.albumArtUri, - trackId = playQueue?.currentTrack?.id ?: 0L, - isPlaying = playbackState == PlaybackState.PLAYING, - ) - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed5s, - initialValue = MiniPlayerUiState(), - ) - - val progress: StateFlow = - _binder - .whenNonNull(0f) { binder -> - combine( - binder.currentPosition, - binder.duration, - ) { position, duration -> - if (duration > 0) (position.toFloat() / duration).coerceIn(0f, 1f) else 0f - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed5s, - initialValue = 0f, - ) + val progress: StateFlow = serviceBinder.miniPlayerProgress(viewModelScope) fun onAction(action: MiniPlayerAction) { - val binder = _binder.value ?: return + val binder = serviceBinder.binderFlow.value ?: return when (action) { MiniPlayerAction.TogglePlayPause -> when (binder.playbackState.value) { PlaybackState.PLAYING -> binder.pauseAudio() diff --git a/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModel.kt b/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModel.kt index 2c7cacca..ca0ef5e8 100644 --- a/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModel.kt +++ b/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModel.kt @@ -6,6 +6,8 @@ import com.happyseal.zenplayer.LogMusic import com.happyseal.zenplayer.core.model.MiniPlayerAction import com.happyseal.zenplayer.core.model.Track import com.happyseal.zenplayer.core.service.ZenPlayerServiceBinder +import com.happyseal.zenplayer.core.service.miniPlayerProgress +import com.happyseal.zenplayer.core.service.miniPlayerUiState import com.happyseal.zenplayer.core.ui.component.MiniPlayerUiState import com.happyseal.zenplayer.core.util.WhileSubscribed5s import com.happyseal.zenplayer.core.util.whenNonNull @@ -42,17 +44,16 @@ class ZenMusicSelectViewModel @Inject constructor( private val musicRepository: MusicRepository, - serviceBinder: ZenPlayerServiceBinder, + private val serviceBinder: ZenPlayerServiceBinder, private val permissionHandler: AudioPermissionHandler, private val playlistRepository: PlaylistRepository, ) : ViewModel() { // --- MusicList 로직 --- - private val _binder = serviceBinder.binderFlow private val _musicListState = MutableStateFlow(MusicListUiState()) private val musicListUiState: StateFlow = - _binder + serviceBinder.binderFlow .whenNonNull(_musicListState) { binder -> combine(_musicListState, binder.currentTrack) { state, currentTrack -> state.copy(currentTrack = currentTrack) @@ -63,33 +64,9 @@ constructor( private val _playlistListState = MutableStateFlow(PlaylistListUiState.Loading) // --- MiniPlayer 로직 --- - private val miniPlayerUiState: StateFlow = - _binder - .whenNonNull(MiniPlayerUiState()) { binder -> - combine( - binder.playQueue, - binder.playbackState, - ) { playQueue, playbackState -> - MiniPlayerUiState( - currentTrackTitle = playQueue?.currentTrack?.title, - currentTrackArtist = playQueue?.currentTrack?.artist, - albumArtUri = playQueue?.currentTrack?.albumArtUri, - trackId = playQueue?.currentTrack?.id ?: 0L, - isPlaying = playbackState == PlaybackState.PLAYING, - ) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed5s, MiniPlayerUiState()) + private val miniPlayerUiState: StateFlow = serviceBinder.miniPlayerUiState(viewModelScope) - private val miniPlayerProgress: StateFlow = - _binder - .whenNonNull(0f) { binder -> - combine( - binder.currentPosition, - binder.duration, - ) { position, duration -> - if (duration > 0) (position.toFloat() / duration).coerceIn(0f, 1f) else 0f - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed5s, 0f) + private val miniPlayerProgress: StateFlow = serviceBinder.miniPlayerProgress(viewModelScope) // --- 통합 UiState --- val uiState: StateFlow = @@ -194,7 +171,7 @@ constructor( val tracks = musicListUiState.value.tracks check(tracks.isNotEmpty()) { "tracks is empty" } require(startIndex in tracks.indices) { "startIndex out of range: $startIndex" } - _binder.value?.setPlayQueueAndPlay(tracks, startIndex, playlistName = null) + serviceBinder.binderFlow.value?.setPlayQueueAndPlay(tracks, startIndex, playlistName = null) } private fun createPlaylist(name: String) { @@ -250,7 +227,7 @@ constructor( } private fun handleMiniPlayerAction(action: MiniPlayerAction) { - val binder = _binder.value ?: return + val binder = serviceBinder.binderFlow.value ?: return when (action) { MiniPlayerAction.TogglePlayPause -> when (binder.playbackState.value) { PlaybackState.PLAYING -> binder.pauseAudio() diff --git a/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt index 822dc3b0..3fc5c19d 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt @@ -48,8 +48,8 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ repo.addTrackToPlaylist(playlistId, makeTrack(2)) val viewModel = makeViewModel(repo) - When("selectPlaylist(playlistId)를 호출하면") { - viewModel.selectPlaylist(playlistId) + When("onAction(SelectPlaylist(playlistId))를 호출하면") { + viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId)) testScheduler.advanceUntilIdle() Then("detailUiState가 Success로 전환되고 플레이리스트 이름과 트랙 목록이 포함된다") { @@ -64,8 +64,8 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ Dispatchers.setMain(testDispatcher) val viewModel = makeViewModel() - When("selectPlaylist(999L)를 호출하면") { - viewModel.selectPlaylist(999L) + When("onAction(SelectPlaylist(999L))를 호출하면") { + viewModel.onAction(PlaylistDetailAction.SelectPlaylist(999L)) testScheduler.advanceUntilIdle() Then("detailUiState가 Error로 전환된다") { @@ -82,11 +82,11 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ repo.addTrackToPlaylist(playlistId, makeTrack(2)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(repo = repo, binder = binder) - viewModel.selectPlaylist(playlistId) + viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId)) testScheduler.advanceUntilIdle() - When("playPlaylist()를 호출하면") { - viewModel.playPlaylist(playlistId) + When("onAction(PlayPlaylist(playlistId))를 호출하면") { + viewModel.onAction(PlaylistDetailAction.PlayPlaylist(playlistId)) testScheduler.advanceUntilIdle() Then("binder의 setPlayQueueAndPlay가 트랙 목록과 함께 호출된다") { @@ -102,10 +102,10 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ val repo = FakePlaylistRepository(throwOnRemoveTrack = RuntimeException("DB 오류")) val viewModel = makeViewModel(repo) - When("removeTrackFromPlaylist()를 호출하면") { + When("onAction(RemoveTrack(1L))를 호출하면") { Then("uiEvent로 RemoveTrackFailed가 발행된다") { viewModel.uiEvent.test { - viewModel.removeTrackFromPlaylist(1L) + viewModel.onAction(PlaylistDetailAction.RemoveTrack(1L)) testScheduler.advanceUntilIdle() val event = awaitItem() @@ -120,29 +120,29 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ Dispatchers.setMain(testDispatcher) val viewModel = makeViewModel() - When("selectPlaylist(0)을 호출하면") { - val exception = runCatching { viewModel.selectPlaylist(0L) }.exceptionOrNull() + When("onAction(SelectPlaylist(0))을 호출하면") { + val exception = runCatching { viewModel.onAction(PlaylistDetailAction.SelectPlaylist(0L)) }.exceptionOrNull() Then("IllegalArgumentException이 발생한다") { exception shouldBe instanceOf() } } - When("removeTrackFromPlaylist(0)을 호출하면") { - val exception = runCatching { viewModel.removeTrackFromPlaylist(0L) }.exceptionOrNull() + When("onAction(RemoveTrack(0))을 호출하면") { + val exception = runCatching { viewModel.onAction(PlaylistDetailAction.RemoveTrack(0L)) }.exceptionOrNull() Then("IllegalArgumentException이 발생한다") { exception shouldBe instanceOf() } } - When("playPlaylist(0, 0)을 호출하면") { - val exception = runCatching { viewModel.playPlaylist(0L, 0) }.exceptionOrNull() + When("onAction(PlayPlaylist(0, 0))을 호출하면") { + val exception = runCatching { viewModel.onAction(PlaylistDetailAction.PlayPlaylist(0L, 0)) }.exceptionOrNull() Then("IllegalArgumentException이 발생한다") { exception shouldBe instanceOf() } } - When("playPlaylist(1, -1)을 호출하면") { - val exception = runCatching { viewModel.playPlaylist(1L, -1) }.exceptionOrNull() + When("onAction(PlayPlaylist(1, -1))을 호출하면") { + val exception = runCatching { viewModel.onAction(PlaylistDetailAction.PlayPlaylist(1L, -1)) }.exceptionOrNull() Then("IllegalArgumentException이 발생한다") { exception shouldBe instanceOf() }