Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ fun NavGraph(
val miniPlayerViewModel = hiltViewModel<MiniPlayerViewModel>()

LaunchedEffect(playlistId) {
viewModel.selectPlaylist(playlistId)
viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId))
}

val uiState by viewModel.detailUiState.collectAsStateWithLifecycle()
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MiniPlayerUiState> = 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<Float> = 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)
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioPermissionStatus> = _status.asStateFlow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ fun PlaylistDetailScreen(
miniPlayerProgress: Float,
uiEvent: Flow<PlaylistDetailUiEvent>,
onAction: (PlaylistDetailAction) -> Unit,
onMiniPlayerAction: (MiniPlayerAction) -> Unit,
onNavigateBack: () -> Unit,
onNavigateToPlayer: () -> Unit,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -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) },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ constructor(
private val playlistRepository: PlaylistRepository,
serviceBinder: ZenPlayerServiceBinder,
) : ViewModel() {
// WhileSubscribed5s 미사용: 외부 Flow를 구독하지 않고 viewModelScope에서
// 직접 값을 push하는 구조이므로 MutableStateFlow.asStateFlow()가 적합
private val _detailUiState =
MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val detailUiState: StateFlow<PlaylistDetailUiState> = _detailUiState.asStateFlow()
Expand All @@ -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
Expand All @@ -58,7 +68,7 @@ constructor(
}
}

fun removeTrackFromPlaylist(trackRowId: Long) {
private fun removeTrackFromPlaylist(trackRowId: Long) {
require(trackRowId > 0) { "유효하지 않은 trackRowId: $trackRowId" }
viewModelScope.launch {
playlistRepository
Expand All @@ -70,7 +80,7 @@ constructor(
}
}

fun playPlaylist(
private fun playPlaylist(
playlistId: Long,
startIndex: Int = 0,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ class TimerEngine(
}

companion object {
// ZenAudioController.POSITION_UPDATE_INTERVAL_MS와 값이 같지만 의도가 다름.
// 이 값은 타이머 진행률 UI 갱신 주기이며, 오디오 재생 위치 추적과는 독립적으로 조정될 수 있음.
internal const val UPDATE_INTERVAL_MS = 100L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MiniPlayerUiState> = serviceBinder.miniPlayerUiState(viewModelScope)

val uiState: StateFlow<MiniPlayerUiState> =
_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<Float> =
_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<Float> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<MusicListUiState> =
_binder
serviceBinder.binderFlow
.whenNonNull(_musicListState) { binder ->
combine(_musicListState, binder.currentTrack) { state, currentTrack ->
state.copy(currentTrack = currentTrack)
Expand All @@ -63,33 +64,9 @@ constructor(
private val _playlistListState = MutableStateFlow<PlaylistListUiState>(PlaylistListUiState.Loading)

// --- MiniPlayer 로직 ---
private val miniPlayerUiState: StateFlow<MiniPlayerUiState> =
_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<MiniPlayerUiState> = serviceBinder.miniPlayerUiState(viewModelScope)

private val miniPlayerProgress: StateFlow<Float> =
_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<Float> = serviceBinder.miniPlayerProgress(viewModelScope)

// --- 통합 UiState ---
val uiState: StateFlow<ZenMusicSelectUiState> =
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading