diff --git a/changes/unreleased/56-native-music-workspace.md b/changes/unreleased/56-native-music-workspace.md new file mode 100644 index 00000000..bc8ffde9 --- /dev/null +++ b/changes/unreleased/56-native-music-workspace.md @@ -0,0 +1,7 @@ +category: feature +issue: 56 +pull: 256 +platforms: android, desktop +user-facing: yes + +Music libraries can use adaptive albums, artists, and tracks navigation, collection artwork, a full collection play action, and a queue that remains available after playback errors. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt index 198a4dc3..800c147e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt @@ -290,6 +290,20 @@ enum class MarketingCaptureScenario( "mobile", "phone-portrait", pullRequest = 221, issue = 52, width = 1_080, height = 1_800, density = 2.625f, ), + MusicLibraryAlbumTracksMobile( + "music-library-album-tracks-mobile", "music-library-album-tracks-mobile.png", + NextcloudPresentation.Adaptive, "Music", "Album track collection", + "Ready", MarketingCapturePurpose.Showcase, + "mobile", "phone-portrait", issue = 56, + width = 1_080, height = 1_800, density = 2.625f, + ), + MusicLibraryPlaybackErrorDesktop( + "music-library-playback-error-desktop", "music-library-playback-error-desktop.png", + NextcloudPresentation.Desktop, "Music", "Playback queue", + "Playback error with queue retained", MarketingCapturePurpose.StateCoverage, + "desktop", "wide", issue = 56, + width = 1_440, height = 900, density = 1f, + ), } internal data class MarketingCaptureRegistryEntry( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarios.kt new file mode 100644 index 00000000..4399c64d --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarios.kt @@ -0,0 +1,215 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.obiente.nextcloudnative.app.design.NextcloudDesktopIdentity +import dev.obiente.nextcloudnative.app.design.NextcloudDesktopShell +import dev.obiente.nextcloudnative.app.design.NextcloudDestination +import dev.obiente.nextcloudnative.app.design.NextcloudPresentation +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationDestination +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.runtime.GenericNativeAppScreen +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor +import dev.obiente.nextcloudnative.nativeui.runtime.NativeAudioRecordPlayer +import dev.obiente.nextcloudnative.nativeui.runtime.NativeAudioTrack +import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext +import dev.obiente.nextcloudnative.nativeui.runtime.NativeMusicAdaptiveNavigationLayout +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.runtime.NativeScreenState +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMusicWorkspaceWidthClass +import dev.obiente.nextcloudnative.nativeui.runtime.planNativeMusicWorkspace + +/** + * Synthetic, network-inert captures of the real adaptive Music workspace and generic renderer. + * + * The fixture intentionally uses generic collection contracts and placeholder media names. It + * never reads a session, server, cache, device library, or personal account data. + */ +@Composable +internal fun MarketingMusicWorkspaceScenario( + scenario: MarketingCaptureScenario, + assets: MarketingCaptureAssets, +) { + require(scenario in musicWorkspaceCaptureScenarios) { + "${scenario.id} is not a Music workspace capture." + } + val desktop = scenario.presentation == NextcloudPresentation.Desktop + val content: @Composable () -> Unit = { + BoxWithConstraints(Modifier.fillMaxSize()) { + val widthClass = nativeMusicWorkspaceWidthClass(maxWidth.value, maxHeight.value) + val intent = remember(widthClass) { + requireNotNull( + planNativeMusicWorkspace( + destinations = marketingMusicDestinations, + selectedViewId = "albums-view", + widthClass = widthClass, + ), + ) + } + Column(Modifier.fillMaxSize()) { + DynamicAppChromeHeader( + title = marketingMusicSchema.app.name, + subtitle = "Your library", + onBack = {}, + compact = !desktop, + onContractInfo = {}, + ) + NativeMusicAdaptiveNavigationLayout( + intent = intent, + onDestinationSelected = {}, + modifier = Modifier.weight(1f), + ) { + Column(Modifier.fillMaxSize()) { + GenericNativeAppScreen( + schema = marketingMusicSchema, + view = marketingMusicTracksView, + state = NativeScreenState.Ready(marketingMusicTracks), + actionExecutor = NativeActionExecutor { + NativeActionExecutionResult.Failure("This fixture is read-only.") + }, + datasetContext = NativeDatasetContext( + parentResourceId = "albums", + parentRecord = marketingMusicAlbum, + ), + audioPlayer = NativeAudioRecordPlayer { _, _, _, _ -> }, + modifier = Modifier.weight(1f), + ) + if (scenario == MarketingCaptureScenario.MusicLibraryPlaybackErrorDesktop) { + NativeAudioMiniPlayer( + queue = NativeAudioQueueState( + tracks = marketingMusicQueueTracks, + currentIndex = 0, + ), + engineState = NativeAudioEngineState( + sourceId = "fixture-track-1", + status = NativeAudioEngineStatus.Error, + positionMillis = 84_000, + durationMillis = 232_000, + error = "Playback paused. The stream can be retried without losing the queue.", + ), + artworkRelativePath = null, + imageLoader = null, + onPrevious = {}, + onTogglePlayback = {}, + onNext = {}, + onSelectTrack = {}, + onSeek = {}, + onStop = {}, + ) + } + } + } + } + } + } + if (desktop) { + NextcloudDesktopShell( + selected = NextcloudDestination.Apps, + onSelected = {}, + identity = NextcloudDesktopIdentity( + displayName = "Obiente", + cloudName = "Nextcloud", + avatar = assets.avatar, + ), + content = content, + ) + } else { + content() + } +} + +internal val musicWorkspaceCaptureScenarios = listOf( + MarketingCaptureScenario.MusicLibraryAlbumTracksMobile, + MarketingCaptureScenario.MusicLibraryPlaybackErrorDesktop, +) + +private val marketingMusicAlbums = ResourceSpec("albums", "Albums", Confidence.verified) +private val marketingMusicArtists = ResourceSpec("artists", "Artists", Confidence.verified) +private val marketingMusicTracksResource = ResourceSpec("tracks", "Tracks", Confidence.verified) + +private val marketingMusicTracksView = ViewSpec( + id = "tracks-view", + title = "Tracks", + resourceId = marketingMusicTracksResource.id, + component = NativeComponent.mediaLibrary, + sourceActionId = "tracks-list", + confidence = Confidence.verified, +) + +private val marketingMusicDestinations = listOf( + DynamicNavigationDestination("albums-view", "Albums", "albums", "albums-list") to + ViewSpec( + id = "albums-view", + title = "Albums", + resourceId = "albums", + component = NativeComponent.mediaLibrary, + sourceActionId = "albums-list", + confidence = Confidence.verified, + ), + DynamicNavigationDestination("artists-view", "Artists", "artists", "artists-list") to + ViewSpec( + id = "artists-view", + title = "Artists", + resourceId = "artists", + component = NativeComponent.mediaLibrary, + sourceActionId = "artists-list", + confidence = Confidence.verified, + ), + DynamicNavigationDestination("tracks-view", "Tracks", "tracks", "tracks-list") to marketingMusicTracksView, +) + +private val marketingMusicSchema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("fixture-media-library", "Music", "1"), + confidence = Confidence.verified, + resources = listOf(marketingMusicAlbums, marketingMusicArtists, marketingMusicTracksResource), + views = marketingMusicDestinations.map { (_, view) -> view }, +) + +private val marketingMusicAlbum = NativeRecord( + id = "fixture-album-1", + values = mapOf( + "name" to "Evening Signals", + "coverUrl" to null, + ), +) + +private val marketingMusicTracks = listOf( + marketingMusicTrack("fixture-track-1", "First light", "1", 232_000), + marketingMusicTrack("fixture-track-2", "Station windows", "2", 198_000), + marketingMusicTrack("fixture-track-3", "Before the rain", "3", 245_000), +) + +private val marketingMusicQueueTracks: List = listOf( + NativeAudioTrack("fixture-track-1", "First light", "Sample artist", "Evening Signals", null, 232_000, emptyList()), + NativeAudioTrack("fixture-track-2", "Station windows", "Sample artist", "Evening Signals", null, 198_000, emptyList()), + NativeAudioTrack("fixture-track-3", "Before the rain", "Sample artist", "Evening Signals", null, 245_000, emptyList()), +) + +private fun marketingMusicTrack( + id: String, + title: String, + trackNumber: String, + durationMillis: Int, +): NativeRecord = NativeRecord( + id = id, + values = mapOf( + "title" to title, + "artist" to "Sample artist", + "album" to "Evening Signals", + "trackNumber" to trackNumber, + "durationMillis" to durationMillis.toString(), + "fileId" to (7_000 + trackNumber.toInt()).toString(), + "mimeType" to "audio/mpeg", + ), +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3641dc61..9e65a664 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -130,7 +130,13 @@ import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionRequest import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext import dev.obiente.nextcloudnative.nativeui.runtime.NativeImageLoader import dev.obiente.nextcloudnative.nativeui.runtime.NativeAudioRecordPlayer +import dev.obiente.nextcloudnative.nativeui.runtime.NativeMusicAdaptiveNavigationLayout import dev.obiente.nextcloudnative.nativeui.runtime.nativeAudioTrack +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMusicActiveNavigationViewId +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMusicWorkspaceWidthClass +import dev.obiente.nextcloudnative.nativeui.runtime.planNativeMusicWorkspace +import dev.obiente.nextcloudnative.nativeui.runtime.preferredNativeMusicLandingViewId +import dev.obiente.nextcloudnative.nativeui.runtime.selectNativeMusicRoot import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.effectiveNativeResourceId import dev.obiente.nextcloudnative.nativeui.runtime.actionBindingValues @@ -463,6 +469,9 @@ fun NextcloudNativeMarketingCapture( MarketingCaptureScenario.TransferDesktopActive, MarketingCaptureScenario.TransferDesktopCompleted, -> MarketingMediaTransferScenario(scenario) + MarketingCaptureScenario.MusicLibraryAlbumTracksMobile, + MarketingCaptureScenario.MusicLibraryPlaybackErrorDesktop, + -> MarketingMusicWorkspaceScenario(scenario, assets) MarketingCaptureScenario.DeckBoardDesktop, MarketingCaptureScenario.DeckBoardMobile, -> MarketingDeckBoardScenario() @@ -1463,7 +1472,9 @@ private fun DynamicDiscoveredAppScreen( val descriptor = discovery.descriptor val schema = remember(descriptor) { descriptor.toNativeAppSchema() } val initialViewId = remember(descriptor, schema) { - descriptor.planDynamicNavigation().rootDestinations.firstOrNull()?.layoutId + val rootDestinations = descriptor.planDynamicNavigation().rootDestinations + preferredNativeMusicLandingViewId(rootDestinations, schema) + ?: rootDestinations.firstOrNull()?.layoutId ?: schema.views.firstOrNull { it.component != NativeComponent.form }?.id ?: schema.views.firstOrNull()?.id } @@ -2152,6 +2163,32 @@ private fun DynamicDiscoveredAppScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val compactLandscape = shouldUseCompactDynamicAppChrome(maxWidth.value, maxHeight.value) + val activeMusicNavigationViewId = remember( + primaryNavigationDestinations, + selectedView.id, + navigationHistory, + ) { + nativeMusicActiveNavigationViewId( + destinations = primaryNavigationDestinations, + selectedViewId = selectedView.id, + navigationHistoryViewIds = navigationHistory.map(DynamicNavigationSnapshot::viewId), + ) + } + val musicWorkspaceIntent = remember( + primaryNavigationDestinations, + activeMusicNavigationViewId, + maxWidth, + maxHeight, + ) { + planNativeMusicWorkspace( + destinations = primaryNavigationDestinations, + selectedViewId = activeMusicNavigationViewId, + widthClass = nativeMusicWorkspaceWidthClass( + widthDp = maxWidth.value, + heightDp = maxHeight.value, + ), + ) + } Column(modifier = Modifier.fillMaxSize()) { DynamicAppChromeHeader( title = descriptor.app.name, @@ -2221,7 +2258,10 @@ private fun DynamicDiscoveredAppScreen( } } } - if (primaryNavigationDestinations.size > 1 || secondaryNavigationDestinations.isNotEmpty()) { + if ( + musicWorkspaceIntent == null && + (primaryNavigationDestinations.size > 1 || secondaryNavigationDestinations.isNotEmpty()) + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -2301,120 +2341,148 @@ private fun DynamicDiscoveredAppScreen( } } } - GenericNativeAppScreen( - schema = schema, - view = selectedView, - state = viewState, - actionExecutor = executor, - selectedRecordId = selectedRecord?.id, - showSelectedRecordDetail = showFallbackRecordDetail, - datasetContext = NativeDatasetContext( - parentResourceId = selectedRecordResourceId, - parentRecord = selectedRecord, - relatedRecords = recordsByResourceId, - ), - onSelectRecord = selectedView.takeIf { - it.component != NativeComponent.detail && it.component != NativeComponent.form - }?.let { - { record -> - rememberCurrentLocation() - val selectedParentResourceId = record.effectiveNativeResourceId(selectedView.resourceId) - val inheritedParameters = inheritDynamicParentParameters( - selectedPathParameterValues = selectedPathParameterValues, - runtimeValues = runtimeValues, - ) - val nextContext = DynamicResourceRecordContext( - resourceId = selectedParentResourceId, - recordId = record.id, - fieldValues = record.values, - parameterValues = inheritedParameters, - actionSafeIdentity = record.actionSafeIdentity, - currentLayoutId = selectedView.id, - ) - val nextPlan = descriptor.planDynamicNavigation(nextContext) - val compositeTarget = schema.views.firstOrNull { candidate -> - candidate.compositeDataGrid?.parentResourceId == selectedParentResourceId - } - val compositeActionIds = compositeTarget?.compositeDataGrid?.let { grid -> - setOf(grid.columnSourceActionId, grid.rowSourceActionId) - }.orEmpty() - val detailResolution = schema.bestDynamicDetailView(selectedParentResourceId) - ?.takeIf { target -> target.id != selectedView.id } - ?.let { target -> - descriptor.resolveDynamicRecordReadParameters(target.sourceActionId, nextContext) - ?.let { parameters -> target to parameters } - } - val detailTarget = detailResolution?.first - val directChild = descriptor.singleSafeContextualChild( - context = nextContext, - hasDedicatedSurface = compositeTarget != null || detailTarget != null, - ) - val preferredCollectionChild = descriptor.preferredSemanticContextualChild(nextContext) - val primaryContentTarget = primaryDynamicContentDestination( - parentResourceId = selectedParentResourceId, - destinations = nextPlan.contextualChildDestinations, - ) - val nextViewId = compositeTarget?.id - ?: primaryContentTarget?.layoutId - ?: preferredCollectionChild?.layoutId - ?: detailTarget?.id - ?: directChild?.layoutId - ?: selectedViewId - val explicitTargetParameters = primaryContentTarget?.pathParameterValues - ?: preferredCollectionChild?.pathParameterValues - ?: directChild?.pathParameterValues - ?: detailResolution?.second - val fallbackTargetParameters = inheritedParameters + - nextPlan.contextualChildDestinations - .filter { destination -> destination.actionId in compositeActionIds } - .flatMap { destination -> destination.pathParameterValues.entries } - .associate(Map.Entry::toPair) - selectedRecord = record - selectedRecordResourceId = selectedParentResourceId - selectedPathParameterValues = resolveDynamicRecordSelectionParameters( - currentViewId = selectedViewId.orEmpty(), - nextViewId = nextViewId.orEmpty(), - currentParameters = selectedPathParameterValues, - explicitTargetParameters = explicitTargetParameters, - fallbackTargetParameters = fallbackTargetParameters, - ) - selectedViewId = nextViewId - } - }, - onActionSucceeded = { - if (schema.action(selectedView.sourceActionId)?.intent == ActionIntent.delete) { + val dynamicScreenContent: @Composable () -> Unit = { + GenericNativeAppScreen( + schema = schema, + view = selectedView, + state = viewState, + actionExecutor = executor, + selectedRecordId = selectedRecord?.id, + showSelectedRecordDetail = showFallbackRecordDetail, + datasetContext = NativeDatasetContext( + parentResourceId = selectedRecordResourceId, + parentRecord = selectedRecord, + relatedRecords = recordsByResourceId, + ), + onSelectRecord = selectedView.takeIf { + it.component != NativeComponent.detail && it.component != NativeComponent.form + }?.let { + { record -> + rememberCurrentLocation() + val selectedParentResourceId = + record.effectiveNativeResourceId(selectedView.resourceId) + val inheritedParameters = inheritDynamicParentParameters( + selectedPathParameterValues = selectedPathParameterValues, + runtimeValues = runtimeValues, + ) + val nextContext = DynamicResourceRecordContext( + resourceId = selectedParentResourceId, + recordId = record.id, + fieldValues = record.values, + parameterValues = inheritedParameters, + actionSafeIdentity = record.actionSafeIdentity, + currentLayoutId = selectedView.id, + ) + val nextPlan = descriptor.planDynamicNavigation(nextContext) + val compositeTarget = schema.views.firstOrNull { candidate -> + candidate.compositeDataGrid?.parentResourceId == selectedParentResourceId + } + val compositeActionIds = compositeTarget?.compositeDataGrid?.let { grid -> + setOf(grid.columnSourceActionId, grid.rowSourceActionId) + }.orEmpty() + val detailResolution = schema.bestDynamicDetailView(selectedParentResourceId) + ?.takeIf { target -> target.id != selectedView.id } + ?.let { target -> + descriptor.resolveDynamicRecordReadParameters( + target.sourceActionId, + nextContext, + )?.let { parameters -> target to parameters } + } + val detailTarget = detailResolution?.first + val directChild = descriptor.singleSafeContextualChild( + context = nextContext, + hasDedicatedSurface = compositeTarget != null || detailTarget != null, + ) + val preferredCollectionChild = + descriptor.preferredSemanticContextualChild(nextContext) + val primaryContentTarget = primaryDynamicContentDestination( + parentResourceId = selectedParentResourceId, + destinations = nextPlan.contextualChildDestinations, + ) + val nextViewId = compositeTarget?.id + ?: primaryContentTarget?.layoutId + ?: preferredCollectionChild?.layoutId + ?: detailTarget?.id + ?: directChild?.layoutId + ?: selectedViewId + val explicitTargetParameters = primaryContentTarget?.pathParameterValues + ?: preferredCollectionChild?.pathParameterValues + ?: directChild?.pathParameterValues + ?: detailResolution?.second + val fallbackTargetParameters = inheritedParameters + + nextPlan.contextualChildDestinations + .filter { destination -> destination.actionId in compositeActionIds } + .flatMap { destination -> destination.pathParameterValues.entries } + .associate(Map.Entry::toPair) + selectedRecord = record + selectedRecordResourceId = selectedParentResourceId + selectedPathParameterValues = resolveDynamicRecordSelectionParameters( + currentViewId = selectedViewId.orEmpty(), + nextViewId = nextViewId.orEmpty(), + currentParameters = selectedPathParameterValues, + explicitTargetParameters = explicitTargetParameters, + fallbackTargetParameters = fallbackTargetParameters, + ) + selectedViewId = nextViewId + } + }, + onActionSucceeded = { + if (schema.action(selectedView.sourceActionId)?.intent == ActionIntent.delete) { + navigationHistory = emptyList() + selectedRecord = null + selectedRecordResourceId = null + selectedPathParameterValues = emptyMap() + selectedViewId = initialViewId + } else { + // Update/config actions return to their previous surface, which immediately + // reloads the authoritative server representation through loadAttempt. + navigateWithinDynamicApp() + } + loadAttempt += 1 + }, + onInlineActionSucceeded = { loadAttempt += 1 }, + onOpenLink = services::openExternalUrl, + imageLoader = imageLoader, + audioPlayer = audioSourceCapability?.let { + NativeAudioRecordPlayer { resource, records, selected, collectionContext -> + val queue = startNativeAudioQueue( + tracks = records.mapNotNull { record -> + nativeAudioTrack(resource, record, collectionContext) + }, + selectedRecordId = selected.id, + ) + playCurrentAudioTrack(queue) + } + }, + mediaArtworkResolver = mediaArtworkResolver, + onLoadMore = onLoadMore.takeUnless { showFallbackRecordDetail }, + loadingMore = loadingMore, + loadMoreError = loadMoreError, + modifier = Modifier.fillMaxSize(), + ) + } + if (musicWorkspaceIntent != null) { + NativeMusicAdaptiveNavigationLayout( + intent = musicWorkspaceIntent, + onDestinationSelected = { destination -> + // Root music navigation deliberately exits a selected album, artist, or + // track detail. Retaining its contextual record bindings would load the + // newly selected library collection with the wrong parent identity. + val selection = selectNativeMusicRoot(destination) navigationHistory = emptyList() - selectedRecord = null - selectedRecordResourceId = null - selectedPathParameterValues = emptyMap() - selectedViewId = initialViewId - } else { - // Update/config actions return to their previous surface, which immediately - // reloads the authoritative server representation through loadAttempt. - navigateWithinDynamicApp() - } - loadAttempt += 1 - }, - onInlineActionSucceeded = { loadAttempt += 1 }, - onOpenLink = services::openExternalUrl, - imageLoader = imageLoader, - audioPlayer = audioSourceCapability?.let { - NativeAudioRecordPlayer { resource, records, selected, collectionContext -> - val queue = startNativeAudioQueue( - tracks = records.mapNotNull { record -> - nativeAudioTrack(resource, record, collectionContext) - }, - selectedRecordId = selected.id, - ) - playCurrentAudioTrack(queue) - } - }, - mediaArtworkResolver = mediaArtworkResolver, - onLoadMore = onLoadMore.takeUnless { showFallbackRecordDetail }, - loadingMore = loadingMore, - loadMoreError = loadMoreError, - modifier = Modifier.weight(1f), - ) + selectedRecord = selection.selectedRecord + selectedRecordResourceId = selection.selectedRecordResourceId + selectedPathParameterValues = selection.pathParameterValues + selectedViewId = selection.viewId + }, + modifier = Modifier.weight(1f), + content = dynamicScreenContent, + ) + } else { + Box(modifier = Modifier.weight(1f)) { + dynamicScreenContent() + } + } if (audioSourceCapability != null && audioQueue.currentTrack != null) { NativeAudioMiniPlayer( queue = audioQueue, @@ -2580,7 +2648,7 @@ internal fun DynamicAppChromeHeader( } @Composable -private fun NativeAudioMiniPlayer( +internal fun NativeAudioMiniPlayer( queue: NativeAudioQueueState, engineState: NativeAudioEngineState, artworkRelativePath: String?, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt index a8f3c7d1..9c297bef 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt @@ -225,10 +225,7 @@ fun GenericNativeAppScreen( GenericNativeSurface.MediaLibrary -> GenericMediaLibraryCollection( presentedResource, presentedRecords, - nativeAudioCollectionContext( - datasetContext.parentResourceId, - datasetContext.parentRecord, - ), + nativeAudioCollectionContext(schema, datasetContext), onSelectRecord, imageLoader, audioPlayer, @@ -1442,10 +1439,22 @@ private fun GenericMediaLibraryCollection( bottom = NextcloudSpacing.XXLarge, ), ) { + if (audioCollectionContext != null) { + item(key = "media-collection-context") { + NativeMediaCollectionHeader( + resource = resource, + records = records, + collectionContext = audioCollectionContext, + imageLoader = imageLoader, + audioPlayer = audioPlayer, + mediaArtworkResolver = mediaArtworkResolver, + ) + } + } items(mediaItems, key = { (record, _) -> record.id }) { (record, presentation) -> val artwork = remember(resource, record, mediaArtworkResolver) { mediaArtworkResolver?.resolve(resource, record) - ?: presentation.fallbackArtworkReference(record.id) + ?: presentation.nativeFallbackArtworkReference(record.id) } val playable = remember(resource, record, audioCollectionContext) { nativeAudioTrack(resource, record, audioCollectionContext) != null @@ -1539,7 +1548,7 @@ private fun GenericMediaLibraryCollection( items(mediaItems, key = { (record, _) -> record.id }) { (record, presentation) -> val artwork = remember(resource, record, mediaArtworkResolver) { mediaArtworkResolver?.resolve(resource, record) - ?: presentation.fallbackArtworkReference(record.id) + ?: presentation.nativeFallbackArtworkReference(record.id) } val interaction = onSelectRecord?.let { callback -> Modifier.clickable { callback(record) } } ?: Modifier Card( @@ -1586,20 +1595,111 @@ private fun GenericMediaLibraryCollection( } } -private fun NativeMediaPresentation.fallbackArtworkReference( - recordId: String, -): NativeMediaArtworkReference { - val fallback = when (kind) { - NativeMediaItemKind.Artist -> NativeMediaArtworkFallback.Artist - NativeMediaItemKind.Album -> NativeMediaArtworkFallback.Album - NativeMediaItemKind.Track -> NativeMediaArtworkFallback.Track - else -> NativeMediaArtworkFallback.Media - } - return NativeMediaArtworkReference( - relativePath = coverUrl, - cacheKey = "${fallback.name.lowercase()}:${recordId.take(128)}:${coverUrl ?: "fallback"}", - fallback = fallback, - ) +@Composable +private fun NativeMediaCollectionHeader( + resource: ResourceSpec, + records: List, + collectionContext: NativeAudioCollectionContext, + imageLoader: NativeImageLoader?, + audioPlayer: NativeAudioRecordPlayer?, + mediaArtworkResolver: NativeMediaArtworkResolver?, +) { + val firstPlayableRecord = remember(resource, records, collectionContext) { + nativeAudioCollectionFirstPlayableRecord(resource, records, collectionContext) + } + val artworkReference = remember( + resource, + firstPlayableRecord, + collectionContext, + mediaArtworkResolver, + ) { + nativeAudioCollectionArtworkReference( + collectionContext = collectionContext, + childResource = resource, + firstPlayableRecord = firstPlayableRecord, + resolver = mediaArtworkResolver, + ) + } + val playableCount = remember(resource, records, collectionContext) { + records.count { record -> nativeAudioTrack(resource, record, collectionContext) != null } + } + Surface( + modifier = Modifier.fillMaxWidth().padding(bottom = NextcloudSpacing.Large), + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val compact = maxWidth < 520.dp + Row( + modifier = Modifier.fillMaxWidth().padding( + if (compact) NextcloudSpacing.Medium else NextcloudSpacing.Large, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + if (compact) NextcloudSpacing.Medium else NextcloudSpacing.Large, + ), + ) { + NativeMediaArtworkThumbnail( + reference = artworkReference, + title = collectionContext.title, + imageLoader = imageLoader, + modifier = Modifier.size(if (compact) 80.dp else 112.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Text( + when (collectionContext.kind) { + NativeAudioCollectionKind.Album -> "Album" + NativeAudioCollectionKind.Artist -> "Artist" + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + collectionContext.title, + style = if (compact) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.headlineSmall + }, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + when (playableCount) { + 0 -> "No playable tracks" + 1 -> "1 track" + else -> "$playableCount tracks" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (firstPlayableRecord != null && audioPlayer != null) { + Button( + onClick = { + audioPlayer.playCollectionIfPossible( + resource, + records, + collectionContext, + ) + }, + modifier = Modifier.heightIn(min = 48.dp), + ) { + Icon( + NextcloudIcons.Play, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text("Play", modifier = Modifier.padding(start = NextcloudSpacing.Small)) + } + } + } + } + } + } } @Composable diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeAudioLibrary.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeAudioLibrary.kt index 08fc07a5..c33b9969 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeAudioLibrary.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeAudioLibrary.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.nativeui.runtime +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec /** @@ -40,6 +41,10 @@ enum class NativeAudioCollectionKind { data class NativeAudioCollectionContext( val kind: NativeAudioCollectionKind, val title: String, + /** The verified parent resource that supplied this child collection. */ + val parentResource: ResourceSpec, + /** The selected parent record, retained for its authoritative title and artwork fields. */ + val parentRecord: NativeRecord, ) enum class NativeMediaArtworkFallback { @@ -154,11 +159,12 @@ internal fun nativeAudioTrack( } internal fun nativeAudioCollectionContext( - parentResourceId: String?, + parentResource: ResourceSpec?, parentRecord: NativeRecord?, ): NativeAudioCollectionContext? { + val resource = parentResource ?: return null val record = parentRecord ?: return null - val semanticResource = parentResourceId?.nativeAudioSemanticKey().orEmpty() + val semanticResource = resource.id.nativeAudioSemanticKey() val kind = when { semanticResource.contains("album") || semanticResource.contains("release") -> NativeAudioCollectionKind.Album @@ -170,7 +176,71 @@ internal fun nativeAudioCollectionContext( ?.trim() ?.takeIf(String::isNotBlank) ?: return null - return NativeAudioCollectionContext(kind, title) + return NativeAudioCollectionContext( + kind = kind, + title = title, + parentResource = resource, + parentRecord = record, + ) +} + +/** + * Resolves the selected collection through the same dataset context used by the generic renderer. + * + * Keeping this lookup here makes the parent binding explicit and testable: a contextual album or + * artist list must never borrow a similarly named record from another resource. + */ +internal fun nativeAudioCollectionContext( + schema: NativeAppSchema, + datasetContext: NativeDatasetContext, +): NativeAudioCollectionContext? = nativeAudioCollectionContext( + parentResource = datasetContext.parentResourceId?.let(schema::resource), + parentRecord = datasetContext.parentRecord, +) + +/** + * Plans collection artwork without promoting an arbitrary first track over the selected parent. + * + * Track rows often omit album artwork and metadata. The selected album or artist record is the + * authoritative context, so use its image first. A track is only a fallback when the parent has + * no usable image path. + */ +internal fun nativeAudioCollectionArtworkReference( + collectionContext: NativeAudioCollectionContext, + childResource: ResourceSpec, + firstPlayableRecord: NativeRecord?, + resolver: NativeMediaArtworkResolver?, +): NativeMediaArtworkReference { + val parentReference = resolver?.resolve( + collectionContext.parentResource, + collectionContext.parentRecord, + ) ?: nativeMediaPresentation( + collectionContext.parentResource, + collectionContext.parentRecord, + ).nativeFallbackArtworkReference(collectionContext.parentRecord.id) + if (parentReference.relativePath != null) return parentReference + + val trackReference = firstPlayableRecord?.let { record -> + resolver?.resolve(childResource, record) + ?: nativeMediaPresentation(childResource, record).nativeFallbackArtworkReference(record.id) + } + return trackReference?.takeIf { reference -> reference.relativePath != null } ?: parentReference +} + +internal fun NativeMediaPresentation.nativeFallbackArtworkReference( + recordId: String, +): NativeMediaArtworkReference { + val fallback = when (kind) { + NativeMediaItemKind.Artist -> NativeMediaArtworkFallback.Artist + NativeMediaItemKind.Album -> NativeMediaArtworkFallback.Album + NativeMediaItemKind.Track -> NativeMediaArtworkFallback.Track + else -> NativeMediaArtworkFallback.Media + } + return NativeMediaArtworkReference( + relativePath = coverUrl, + cacheKey = "${fallback.name.lowercase()}:${recordId.take(128)}:${coverUrl ?: "fallback"}", + fallback = fallback, + ) } private fun NativeRecord.nativeAudioScalar(vararg aliases: String): String? { @@ -238,3 +308,30 @@ fun interface NativeAudioRecordPlayer { collectionContext: NativeAudioCollectionContext?, ) } + +/** Returns the first record with a verified playable audio representation in collection order. */ +internal fun nativeAudioCollectionFirstPlayableRecord( + resource: ResourceSpec, + records: List, + collectionContext: NativeAudioCollectionContext?, +): NativeRecord? = records.firstOrNull { record -> + nativeAudioTrack(resource, record, collectionContext) != null +} + +/** + * Starts a collection queue from its first playable row while preserving the complete record set. + * + * The player receives every source record so it can build a queue, rather than only the first + * play target. This is shared by the collection header and is intentionally a no-op when a + * collection contains no playable audio. + */ +internal fun NativeAudioRecordPlayer.playCollectionIfPossible( + resource: ResourceSpec, + records: List, + collectionContext: NativeAudioCollectionContext, +): Boolean { + val selected = nativeAudioCollectionFirstPlayableRecord(resource, records, collectionContext) + ?: return false + play(resource, records, selected, collectionContext) + return true +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspace.kt new file mode 100644 index 00000000..cfa6e792 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspace.kt @@ -0,0 +1,643 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudRadii +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing +import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationDestination +import dev.obiente.nextcloudnative.nativeui.model.EvidenceSource +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec + +internal enum class NativeMusicSectionKind { + Albums, + Artists, + Tracks, + Folders, + Playlists, + Podcasts, + Radio, + Genres, + Search, + Settings, + Other, +} + +internal enum class NativeMusicWorkspaceWidthClass { + Compact, + Medium, + Expanded, +} + +internal enum class NativeMusicNavigationPlacement { + TabStrip, + Rail, + Sidebar, +} + +internal data class NativeMusicWorkspaceDestination( + val viewId: String, + val label: String, + val resourceId: String, + val pathParameterValues: Map, + val kind: NativeMusicSectionKind, +) + +internal data class NativeMusicWorkspaceIntent( + val destinations: List, + val activeDestination: NativeMusicWorkspaceDestination, + val placement: NativeMusicNavigationPlacement, +) { + init { + require(destinations.isNotEmpty()) { "A music workspace needs at least one destination." } + require(destinations.map { it.viewId }.distinct().size == destinations.size) { + "Music workspace destinations must have unique view ids." + } + require(activeDestination in destinations) { + "The selected music destination must be available." + } + } +} + +/** + * The complete state change for choosing a root Music destination. + * + * Contextual album, artist, and track bindings must be cleared when moving back to a root library + * section. Returning this data as one value prevents host state from partially retaining a child + * selection while rendering another collection. + */ +internal data class NativeMusicRootSelection( + val viewId: String, + val pathParameterValues: Map, + val navigationHistoryViewIds: List = emptyList(), + val selectedRecord: NativeRecord? = null, + val selectedRecordResourceId: String? = null, +) + +internal fun selectNativeMusicRoot( + destination: NativeMusicWorkspaceDestination, +): NativeMusicRootSelection = NativeMusicRootSelection( + viewId = destination.viewId, + pathParameterValues = destination.pathParameterValues, +) + +/** + * Plans a native media-library workspace from declared collection shape. + * + * The planner deliberately ignores the app id. A workspace is enabled only when at least two + * core library collections are independently declared, or when a track collection is accompanied + * by two other media collections. Non-media destinations remain reachable after opt-in. + */ +internal fun planNativeMusicWorkspace( + destinations: List>, + selectedViewId: String, + widthClass: NativeMusicWorkspaceWidthClass, +): NativeMusicWorkspaceIntent? { + val mapped = destinations + .distinctBy { (_, view) -> view.id } + .map { (destination, view) -> + NativeMusicWorkspaceDestination( + viewId = view.id, + label = (destination.label.takeIf(String::isNotBlank) ?: view.title) + .removeMusicApiPrefix(), + resourceId = destination.resourceId, + pathParameterValues = destination.pathParameterValues, + kind = nativeMusicSectionKind(destination, view), + ) + } + val mediaDestinations = destinations.mapNotNull { (destination, view) -> + nativeMusicSectionKind(destination, view) + .takeIf { view.component == NativeComponent.mediaLibrary && it.isMediaLibrarySection() } + } + val coreSectionCount = mediaDestinations + .filter { it in CORE_MUSIC_SECTIONS } + .distinct() + .size + val supportingSectionCount = mediaDestinations + .filter { it in SUPPORTING_MUSIC_SECTIONS } + .distinct() + .size + val hasTrackCollection = NativeMusicSectionKind.Tracks in mediaDestinations + if (coreSectionCount < 2 && !(hasTrackCollection && supportingSectionCount >= 2)) return null + + val sorted = mapped.sortedWith( + compareBy( + { MUSIC_SECTION_ORDER.indexOf(it.kind).takeIf { index -> index >= 0 } ?: Int.MAX_VALUE }, + { it.label.lowercase() }, + ), + ) + val active = sorted.firstOrNull { it.viewId == selectedViewId } ?: sorted.firstOrNull() ?: return null + return NativeMusicWorkspaceIntent( + destinations = sorted, + activeDestination = active, + placement = when (widthClass) { + NativeMusicWorkspaceWidthClass.Compact -> NativeMusicNavigationPlacement.TabStrip + NativeMusicWorkspaceWidthClass.Medium -> NativeMusicNavigationPlacement.Rail + NativeMusicWorkspaceWidthClass.Expanded -> NativeMusicNavigationPlacement.Sidebar + }, + ) +} + +/** + * Keeps the library navigation visible while a person is inspecting a child collection. + * + * A selected album or artist can navigate to a contextual track view that is not itself a root + * destination. In that case the most recent root destination remains the active library section + * on compact tabs, medium rails, and expanded sidebars. + */ +internal fun nativeMusicActiveNavigationViewId( + destinations: List>, + selectedViewId: String, + navigationHistoryViewIds: List, +): String { + val rootViewIds = destinations.mapTo(hashSetOf()) { (_, view) -> view.id } + return selectedViewId.takeIf(rootViewIds::contains) + ?: navigationHistoryViewIds.asReversed().firstOrNull(rootViewIds::contains) + ?: selectedViewId +} + +internal fun preferredNativeMusicLandingViewId( + destinations: List, + schema: NativeAppSchema, +): String? { + val paired = destinations.mapNotNull { destination -> + schema.views.firstOrNull { view -> view.id == destination.layoutId } + ?.let { view -> destination to view } + } + val intent = planNativeMusicWorkspace( + destinations = paired, + selectedViewId = "", + widthClass = NativeMusicWorkspaceWidthClass.Compact, + ) ?: return null + val mediaLibraryViewIds = paired + .filter { (_, view) -> view.component == NativeComponent.mediaLibrary } + .mapTo(hashSetOf()) { (_, view) -> view.id } + return intent.destinations.firstOrNull { destination -> + destination.viewId in mediaLibraryViewIds && + destination.kind in PREFERRED_MUSIC_LANDING_SECTIONS + }?.viewId +} + +internal fun nativeMusicWorkspaceWidthClass( + widthDp: Float, + heightDp: Float, +): NativeMusicWorkspaceWidthClass = when { + widthDp < 700f || heightDp < 420f -> NativeMusicWorkspaceWidthClass.Compact + widthDp < 1_000f -> NativeMusicWorkspaceWidthClass.Medium + else -> NativeMusicWorkspaceWidthClass.Expanded +} + +@Composable +internal fun NativeMusicAdaptiveNavigationLayout( + intent: NativeMusicWorkspaceIntent, + onDestinationSelected: (NativeMusicWorkspaceDestination) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val latestContent = rememberUpdatedState(content) + val movableContent = remember { + movableContentOf { + latestContent.value() + } + } + when (intent.placement) { + NativeMusicNavigationPlacement.TabStrip -> Column(modifier.fillMaxSize()) { + NativeMusicTabStrip(intent, onDestinationSelected) + Box(Modifier.fillMaxWidth().weight(1f)) { + movableContent() + } + } + NativeMusicNavigationPlacement.Rail -> Row(modifier.fillMaxSize()) { + NativeMusicRail(intent, onDestinationSelected) + Box(Modifier.fillMaxHeight().weight(1f)) { + movableContent() + } + } + NativeMusicNavigationPlacement.Sidebar -> Row(modifier.fillMaxSize()) { + NativeMusicSidebar(intent, onDestinationSelected) + Box(Modifier.fillMaxHeight().weight(1f)) { + movableContent() + } + } + } +} + +@Composable +private fun NativeMusicTabStrip( + intent: NativeMusicWorkspaceIntent, + onDestinationSelected: (NativeMusicWorkspaceDestination) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + LazyRow( + modifier = Modifier.fillMaxWidth().selectableGroup(), + contentPadding = PaddingValues( + horizontal = NextcloudSpacing.Medium, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + items(intent.destinations, key = NativeMusicWorkspaceDestination::viewId) { destination -> + val selected = destination == intent.activeDestination + Column( + modifier = Modifier + .widthIn(min = 88.dp) + .heightIn(min = 52.dp) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onDestinationSelected(destination) }, + ) + .padding(horizontal = NextcloudSpacing.Medium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Icon( + nativeMusicSectionIcon(destination.kind), + contentDescription = null, + modifier = Modifier.size(19.dp), + tint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + destination.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + Modifier + .fillMaxWidth() + .height(3.dp) + .background( + if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + ), + ) + } + } + } + HorizontalDivider() + } +} + +@Composable +private fun NativeMusicRail( + intent: NativeMusicWorkspaceIntent, + onDestinationSelected: (NativeMusicWorkspaceDestination) -> Unit, +) { + Row(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier + .width(104.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.Medium, + ), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + intent.destinations.forEach { destination -> + NativeMusicRailDestination( + destination = destination, + selected = destination == intent.activeDestination, + onClick = { onDestinationSelected(destination) }, + ) + } + } + VerticalDivider() + } +} + +@Composable +private fun NativeMusicRailDestination( + destination: NativeMusicWorkspaceDestination, + selected: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .selectable( + selected = selected, + role = Role.Tab, + onClick = onClick, + ), + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + shape = RoundedCornerShape(NextcloudRadii.Medium), + ) { + Column( + modifier = Modifier.padding( + horizontal = NextcloudSpacing.XSmall, + vertical = NextcloudSpacing.Small, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + Icon( + nativeMusicSectionIcon(destination.kind), + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + destination.label, + style = MaterialTheme.typography.labelSmall, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NativeMusicSidebar( + intent: NativeMusicWorkspaceIntent, + onDestinationSelected: (NativeMusicWorkspaceDestination) -> Unit, +) { + Row(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier + .width(224.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + Text( + "Your library", + modifier = Modifier.padding( + horizontal = NextcloudSpacing.Medium, + vertical = NextcloudSpacing.Small, + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + intent.destinations.forEach { destination -> + val selected = destination == intent.activeDestination + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onDestinationSelected(destination) }, + ), + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + shape = RoundedCornerShape(NextcloudRadii.Medium), + ) { + Row( + modifier = Modifier.padding( + horizontal = NextcloudSpacing.Medium, + vertical = NextcloudSpacing.Medium, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Icon( + nativeMusicSectionIcon(destination.kind), + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + destination.label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + VerticalDivider() + } +} + +private fun nativeMusicSectionKind( + destination: DynamicNavigationDestination, + view: ViewSpec, +): NativeMusicSectionKind { + val stableWords = semanticMusicWords( + destination.resourceId, + destination.actionId, + view.resourceId, + view.sourceActionId, + ) + val verifiedWords = view.evidence + .asSequence() + .filter { evidence -> evidence.source in VERIFIED_MUSIC_SEMANTIC_SOURCES } + .flatMap { evidence -> evidence.detail.splitMusicSemanticWords().asSequence() } + .toSet() + val contractKind = nativeMusicSectionKindFromWords(stableWords + verifiedWords) + if (view.component != NativeComponent.mediaLibrary) { + return contractKind.takeIf { it in NON_LIBRARY_MUSIC_SECTIONS } + ?: destination.label.boundedMusicLabelKind() + .takeIf { it in NON_LIBRARY_MUSIC_SECTIONS } + ?: NativeMusicSectionKind.Other + } + return contractKind + ?: destination.label.boundedMusicLabelKind() + ?: NativeMusicSectionKind.Other +} + +private fun nativeMusicSectionKindFromWords(words: Set): NativeMusicSectionKind? = when { + words.any { it in setOf("track", "tracks", "song", "songs") } -> NativeMusicSectionKind.Tracks + words.any { it in setOf("album", "albums", "release", "releases") } -> NativeMusicSectionKind.Albums + words.any { it in setOf("artist", "artists", "composer", "composers") } -> NativeMusicSectionKind.Artists + words.any { it in setOf("folder", "folders", "directory", "directories") } -> NativeMusicSectionKind.Folders + words.any { it in setOf("playlist", "playlists", "queue", "queues") } -> NativeMusicSectionKind.Playlists + words.any { it in setOf("podcast", "podcasts", "episode", "episodes", "channel", "channels") } -> + NativeMusicSectionKind.Podcasts + words.any { it in setOf("radio", "station", "stations") } -> NativeMusicSectionKind.Radio + words.any { it in setOf("genre", "genres") } -> NativeMusicSectionKind.Genres + words.any { it in setOf("search", "find", "query") } -> NativeMusicSectionKind.Search + words.any { it in setOf("setting", "settings", "preference", "preferences") } -> + NativeMusicSectionKind.Settings + else -> null +} + +private fun NativeMusicSectionKind.isMediaLibrarySection(): Boolean = + this in CORE_MUSIC_SECTIONS || this in SUPPORTING_MUSIC_SECTIONS + +private fun String.removeMusicApiPrefix(): String = + removePrefix("API ").removePrefix("Api ").removePrefix("api ").trim() + +/** + * Human-facing labels are a last resort because they are localized and often describe a detail + * view. Restrict the fallback to exact, short collection labels on a declared media-library view. + */ +private fun String.boundedMusicLabelKind(): NativeMusicSectionKind? = when ( + removeMusicApiPrefix().lowercase().filter(Char::isLetterOrDigit) +) { + "album", "albums", "allalbums", "release", "releases" -> NativeMusicSectionKind.Albums + "artist", "artists", "composer", "composers", "allartists" -> NativeMusicSectionKind.Artists + "track", "tracks", "song", "songs", "allsongs", "alltracks" -> NativeMusicSectionKind.Tracks + "folder", "folders", "directory", "directories" -> NativeMusicSectionKind.Folders + "playlist", "playlists", "queue", "queues" -> NativeMusicSectionKind.Playlists + "podcast", "podcasts", "episode", "episodes", "channel", "channels" -> NativeMusicSectionKind.Podcasts + "radio", "station", "stations" -> NativeMusicSectionKind.Radio + "genre", "genres" -> NativeMusicSectionKind.Genres + "search", "find" -> NativeMusicSectionKind.Search + "setting", "settings", "preference", "preferences" -> NativeMusicSectionKind.Settings + else -> null +} + +private fun semanticMusicWords(vararg values: String): Set = values + .asSequence() + .flatMap { value -> value.splitMusicSemanticWords().asSequence() } + .toSet() + +private fun String.splitMusicSemanticWords(): List { + if (isBlank()) return emptyList() + val separated = buildString(length + 8) { + this@splitMusicSemanticWords.forEachIndexed { index, character -> + val previous = this@splitMusicSemanticWords.getOrNull(index - 1) + if ( + index > 0 && + character.isUpperCase() && + previous != null && + (previous.isLowerCase() || previous.isDigit()) + ) { + append(' ') + } + append(character.takeIf(Char::isLetterOrDigit) ?: ' ') + } + } + return separated.lowercase().split(' ').filter(String::isNotBlank) +} + +private fun nativeMusicSectionIcon(kind: NativeMusicSectionKind): ImageVector = when (kind) { + NativeMusicSectionKind.Albums -> NextcloudIcons.Image + NativeMusicSectionKind.Artists -> NextcloudIcons.People + NativeMusicSectionKind.Tracks -> NextcloudIcons.Play + NativeMusicSectionKind.Folders -> NextcloudIcons.Folder + NativeMusicSectionKind.Playlists -> NextcloudIcons.ListView + NativeMusicSectionKind.Podcasts -> NextcloudIcons.Activity + NativeMusicSectionKind.Radio -> NextcloudIcons.Cloud + NativeMusicSectionKind.Genres -> NextcloudIcons.Tag + NativeMusicSectionKind.Search -> NextcloudIcons.Search + NativeMusicSectionKind.Settings -> NextcloudIcons.Settings + NativeMusicSectionKind.Other -> NextcloudIcons.Apps +} + +private val CORE_MUSIC_SECTIONS = setOf( + NativeMusicSectionKind.Albums, + NativeMusicSectionKind.Artists, + NativeMusicSectionKind.Tracks, +) + +private val SUPPORTING_MUSIC_SECTIONS = setOf( + NativeMusicSectionKind.Folders, + NativeMusicSectionKind.Playlists, + NativeMusicSectionKind.Podcasts, + NativeMusicSectionKind.Radio, + NativeMusicSectionKind.Genres, +) + +private val PREFERRED_MUSIC_LANDING_SECTIONS = listOf( + NativeMusicSectionKind.Albums, + NativeMusicSectionKind.Artists, + NativeMusicSectionKind.Tracks, + NativeMusicSectionKind.Folders, + NativeMusicSectionKind.Playlists, + NativeMusicSectionKind.Podcasts, + NativeMusicSectionKind.Radio, + NativeMusicSectionKind.Genres, +) + +private val NON_LIBRARY_MUSIC_SECTIONS = setOf( + NativeMusicSectionKind.Search, + NativeMusicSectionKind.Settings, +) + +private val VERIFIED_MUSIC_SEMANTIC_SOURCES = setOf( + EvidenceSource.capability, + EvidenceSource.openApi, + EvidenceSource.verifiedAdapter, + EvidenceSource.verifiedAppPackage, + EvidenceSource.appStoreLinkedSourceTag, +) + +private val MUSIC_SECTION_ORDER = PREFERRED_MUSIC_LANDING_SECTIONS + listOf( + NativeMusicSectionKind.Search, + NativeMusicSectionKind.Settings, + NativeMusicSectionKind.Other, +) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarioTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarioTest.kt new file mode 100644 index 00000000..766c4f8a --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingMusicWorkspaceCaptureScenarioTest.kt @@ -0,0 +1,25 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.app.design.NextcloudPresentation +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MarketingMusicWorkspaceCaptureScenarioTest { + @Test + fun `music workspace captures cover compact library and desktop playback recovery`() { + assertEquals( + listOf( + MarketingCaptureScenario.MusicLibraryAlbumTracksMobile, + MarketingCaptureScenario.MusicLibraryPlaybackErrorDesktop, + ), + musicWorkspaceCaptureScenarios, + ) + assertTrue(marketingCaptureScenarios.containsAll(musicWorkspaceCaptureScenarios)) + assertEquals( + setOf(NextcloudPresentation.Adaptive, NextcloudPresentation.Desktop), + musicWorkspaceCaptureScenarios.map(MarketingCaptureScenario::presentation).toSet(), + ) + assertEquals(setOf(56), musicWorkspaceCaptureScenarios.mapNotNull(MarketingCaptureScenario::issue).toSet()) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NativeAudioPlaybackTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NativeAudioPlaybackTest.kt index 1309b6f9..117f1bb2 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NativeAudioPlaybackTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NativeAudioPlaybackTest.kt @@ -53,7 +53,10 @@ class NativeAudioPlaybackTest { id = "784", values = mapOf("name" to "Selected album"), ) - val context = nativeAudioCollectionContext("albums", album) + val context = nativeAudioCollectionContext( + ResourceSpec("albums", "Albums", Confidence.verified), + album, + ) val track = nativeAudioTrack( trackResource, trackRecord().copy( @@ -310,6 +313,24 @@ class NativeAudioPlaybackTest { assertEquals("one", queue.next().previous().currentTrack?.recordId) } + @Test + fun `playback error retains the selected queue item for recovery and inspection`() { + val queue = startNativeAudioQueue( + tracks = listOf(nativeTrack("one", 1), nativeTrack("two", 2)), + selectedRecordId = "one", + ) + val error = NativeAudioEngineState( + sourceId = "one:9001:audio/mpeg", + status = NativeAudioEngineStatus.Error, + error = "The stream ended before playback started.", + ) + + assertEquals("one", queue.currentTrack?.recordId) + assertEquals(NativeAudioEngineStatus.Error, error.status) + assertEquals("The stream ended before playback started.", error.error) + assertEquals(2, queue.tracks.size) + } + @Test fun `playback url never accepts credentials in server url`() { val source = NativeAudioPlaybackSource( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspaceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspaceTest.kt new file mode 100644 index 00000000..f1ae0d31 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMusicWorkspaceTest.kt @@ -0,0 +1,479 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationDestination +import dev.obiente.nextcloudnative.nativeui.model.Evidence +import dev.obiente.nextcloudnative.nativeui.model.EvidenceSource +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeMusicWorkspaceTest { + @Test + fun `declared library shape enables ordered adaptive navigation without app identity`() { + val destinations = listOf( + destination("settings", "Preferences", NativeComponent.detail), + destination("songs", "All songs"), + destination("releases", "Releases"), + destination("composers", "Composers"), + destination("directories", "Directories"), + destination("stations", "Internet stations"), + ) + + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = destinations, + selectedViewId = "songs-view", + widthClass = NativeMusicWorkspaceWidthClass.Expanded, + ), + ) + + assertEquals(NativeMusicNavigationPlacement.Sidebar, intent.placement) + assertEquals("songs-view", intent.activeDestination.viewId) + assertEquals( + listOf( + NativeMusicSectionKind.Albums, + NativeMusicSectionKind.Artists, + NativeMusicSectionKind.Tracks, + NativeMusicSectionKind.Folders, + NativeMusicSectionKind.Radio, + NativeMusicSectionKind.Settings, + ), + intent.destinations.map(NativeMusicWorkspaceDestination::kind), + ) + } + + @Test + fun `track collection with supporting media sections enables a library workspace`() { + val intent = planNativeMusicWorkspace( + destinations = listOf( + destination("tracks", "Tracks"), + destination("playlists", "Playlists"), + destination("podcastChannels", "Podcast channels"), + ), + selectedViewId = "tracks-view", + widthClass = NativeMusicWorkspaceWidthClass.Compact, + ) + + assertNotNull(intent) + assertEquals(NativeMusicNavigationPlacement.TabStrip, intent.placement) + } + + @Test + fun `compound album track resource remains a track collection`() { + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = listOf( + destination("albums", "Albums"), + destination("artists", "Artists"), + destination("albumTracks", "Album tracks"), + ), + selectedViewId = "albumTracks-view", + widthClass = NativeMusicWorkspaceWidthClass.Medium, + ), + ) + + assertEquals( + NativeMusicSectionKind.Tracks, + intent.destinations.single { it.viewId == "albumTracks-view" }.kind, + ) + } + + @Test + fun `ambiguous single media collection does not replace generic navigation`() { + val intent = planNativeMusicWorkspace( + destinations = listOf( + destination("albums", "Albums"), + destination("settings", "Settings", NativeComponent.detail), + destination("audit", "Audit", NativeComponent.dataTable), + ), + selectedViewId = "albums-view", + widthClass = NativeMusicWorkspaceWidthClass.Medium, + ) + + assertNull(intent) + } + + @Test + fun `preferred landing is albums then artists then tracks`() { + val destinations = listOf( + destination("tracks", "All tracks"), + destination("artists", "Artists"), + destination("albums", "Albums"), + destination("settings", "Settings", NativeComponent.detail), + ) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("unrelated-id", "Sound collection", "1"), + confidence = Confidence.verified, + views = destinations.map { (_, view) -> view }, + ) + + assertEquals( + "albums-view", + preferredNativeMusicLandingViewId( + destinations = destinations.map { (destination, _) -> destination }, + schema = schema, + ), + ) + } + + @Test + fun `preferred landing ignores a non-library view named albums`() { + val destinations = listOf( + destination("albumSettings", "Albums", NativeComponent.detail), + destination("artists", "Artists"), + destination("tracks", "All tracks"), + ) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("unrelated-id", "Sound collection", "1"), + confidence = Confidence.verified, + views = destinations.map { (_, view) -> view }, + ) + + assertEquals( + "artists-view", + preferredNativeMusicLandingViewId( + destinations = destinations.map { (destination, _) -> destination }, + schema = schema, + ), + ) + } + + @Test + fun `short landscape uses tabs while wider workspaces use rails and sidebars`() { + assertEquals( + NativeMusicWorkspaceWidthClass.Compact, + nativeMusicWorkspaceWidthClass(widthDp = 840f, heightDp = 390f), + ) + assertEquals( + NativeMusicWorkspaceWidthClass.Medium, + nativeMusicWorkspaceWidthClass(widthDp = 840f, heightDp = 700f), + ) + assertEquals( + NativeMusicWorkspaceWidthClass.Expanded, + nativeMusicWorkspaceWidthClass(widthDp = 1_200f, heightDp = 800f), + ) + } + + @Test + fun `every adaptive width keeps the selected root section and selects its matching placement`() { + val destinations = listOf( + destination("albums", "Albums"), + destination("artists", "Artists"), + destination("tracks", "Tracks"), + ) + + NativeMusicWorkspaceWidthClass.entries.forEach { widthClass -> + val intent = assertNotNull( + planNativeMusicWorkspace(destinations, "artists-view", widthClass), + ) + + assertEquals("artists-view", intent.activeDestination.viewId) + assertEquals( + when (widthClass) { + NativeMusicWorkspaceWidthClass.Compact -> NativeMusicNavigationPlacement.TabStrip + NativeMusicWorkspaceWidthClass.Medium -> NativeMusicNavigationPlacement.Rail + NativeMusicWorkspaceWidthClass.Expanded -> NativeMusicNavigationPlacement.Sidebar + }, + intent.placement, + ) + } + } + + @Test + fun `selected album keeps its root library section active while tracks are open`() { + val destinations = listOf( + destination("albums", "Albums"), + destination("artists", "Artists"), + destination("tracks", "Tracks"), + ) + + val activeViewId = nativeMusicActiveNavigationViewId( + destinations = destinations, + selectedViewId = "albumTracks-view", + navigationHistoryViewIds = listOf("albums-view", "album-detail-view"), + ) + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = destinations, + selectedViewId = activeViewId, + widthClass = NativeMusicWorkspaceWidthClass.Compact, + ), + ) + + assertEquals("albums-view", activeViewId) + assertEquals("albums-view", intent.activeDestination.viewId) + } + + @Test + fun `root selection clears child bindings before loading another library section`() { + val destination = NativeMusicWorkspaceDestination( + viewId = "artists-view", + label = "Artists", + resourceId = "artists", + pathParameterValues = mapOf("collection" to "artists"), + kind = NativeMusicSectionKind.Artists, + ) + + val selection = selectNativeMusicRoot(destination) + + assertEquals("artists-view", selection.viewId) + assertEquals(mapOf("collection" to "artists"), selection.pathParameterValues) + assertTrue(selection.navigationHistoryViewIds.isEmpty()) + assertNull(selection.selectedRecord) + assertNull(selection.selectedRecordResourceId) + } + + @Test + fun `stable contract identifiers classify localized collection labels`() { + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = listOf( + destination("albumCatalog", "Albumes"), + destination("artistDirectory", "Artistas"), + destination("trackIndex", "Pistas"), + ), + selectedViewId = "trackIndex-view", + widthClass = NativeMusicWorkspaceWidthClass.Medium, + ), + ) + + assertEquals( + listOf( + NativeMusicSectionKind.Albums, + NativeMusicSectionKind.Artists, + NativeMusicSectionKind.Tracks, + ), + intent.destinations.map(NativeMusicWorkspaceDestination::kind), + ) + } + + @Test + fun `verified semantic evidence classifies opaque localized collection contracts`() { + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = listOf( + destination( + resourceId = "catalogA", + label = "Coleccion", + evidence = listOf(Evidence(EvidenceSource.verifiedAdapter, "semantic: albums")), + ), + destination("artistDirectory", "Artistas"), + destination("trackIndex", "Pistas"), + ), + selectedViewId = "catalogA-view", + widthClass = NativeMusicWorkspaceWidthClass.Expanded, + ), + ) + + assertEquals(NativeMusicSectionKind.Albums, intent.activeDestination.kind) + } + + @Test + fun `descriptive artist label cannot turn a non-library settings view into an artist section`() { + val intent = assertNotNull( + planNativeMusicWorkspace( + destinations = listOf( + destination("albums", "Albums"), + destination("tracks", "Tracks"), + destination("metadata", "Artist settings", NativeComponent.detail), + ), + selectedViewId = "albums-view", + widthClass = NativeMusicWorkspaceWidthClass.Medium, + ), + ) + + assertEquals( + NativeMusicSectionKind.Other, + intent.destinations.single { it.viewId == "metadata-view" }.kind, + ) + } + + @Test + fun `collection artwork uses authoritative selected parent before first track fallback`() { + val albumResource = ResourceSpec("albums", "Albums", Confidence.verified) + val trackResource = ResourceSpec("tracks", "Tracks", Confidence.verified) + val parent = NativeRecord( + id = "album-42", + values = mapOf( + "title" to "Selected album", + "coverUrl" to "/artwork/album-42.jpg", + ), + ) + val context = assertNotNull(nativeAudioCollectionContext(albumResource, parent)) + val track = NativeRecord( + id = "track-7", + values = mapOf( + "title" to "First track", + "coverUrl" to "/artwork/track-7.jpg", + ), + ) + val resolverCalls = mutableListOf() + val resolver = NativeMediaArtworkResolver { resource, record -> + resolverCalls += "${resource.id}:${record.id}" + NativeMediaArtworkReference( + relativePath = "/artwork/${resource.id}-${record.id}.jpg", + cacheKey = "${resource.id}:${record.id}", + fallback = NativeMediaArtworkFallback.Album, + ) + } + + val artwork = nativeAudioCollectionArtworkReference( + collectionContext = context, + childResource = trackResource, + firstPlayableRecord = track, + resolver = resolver, + ) + + assertEquals("/artwork/albums-album-42.jpg", artwork.relativePath) + assertEquals(NativeMediaArtworkFallback.Album, artwork.fallback) + assertEquals(listOf("albums:album-42"), resolverCalls) + } + + @Test + fun `renderer dataset context resolves only its declared parent collection`() { + val albums = ResourceSpec("albums", "Albums", Confidence.verified) + val tracks = ResourceSpec("tracks", "Tracks", Confidence.verified) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("generic-library", "Media library", "1"), + confidence = Confidence.verified, + resources = listOf(albums, tracks), + ) + val parent = NativeRecord("album-42", values = mapOf("name" to "Selected album")) + + val context = nativeAudioCollectionContext( + schema, + NativeDatasetContext(parentResourceId = "albums", parentRecord = parent), + ) + + assertEquals(NativeAudioCollectionKind.Album, context?.kind) + assertEquals(albums, context?.parentResource) + assertEquals(parent, context?.parentRecord) + assertNull( + nativeAudioCollectionContext( + schema, + NativeDatasetContext(parentResourceId = "missing", parentRecord = parent), + ), + ) + } + + @Test + fun `collection play action queues every record and starts the first playable record`() { + val resource = ResourceSpec("tracks", "Tracks", Confidence.verified) + val context = assertNotNull( + nativeAudioCollectionContext( + ResourceSpec("albums", "Albums", Confidence.verified), + NativeRecord("album-42", values = mapOf("name" to "Selected album")), + ), + ) + val unavailable = NativeRecord( + "note-1", + values = mapOf("title" to "Album notes", "artist" to "Example artist"), + ) + val playable = NativeRecord( + "track-7", + values = mapOf( + "title" to "First playable track", + "artist" to "Example artist", + "fileId" to "7001", + "mimeType" to "audio/mpeg", + ), + ) + val laterPlayable = playable.copy(id = "track-8", values = playable.values + ("title" to "Later track")) + var received: List? = null + var selected: NativeRecord? = null + var receivedContext: NativeAudioCollectionContext? = null + val player = NativeAudioRecordPlayer { _, records, start, parentContext -> + received = records + selected = start + receivedContext = parentContext + } + + assertTrue(player.playCollectionIfPossible(resource, listOf(unavailable, playable, laterPlayable), context)) + assertEquals(listOf(unavailable, playable, laterPlayable), received) + assertEquals(playable, selected) + assertEquals(context, receivedContext) + } + + @Test + fun `collection play action is a no op when no audio representation is declared`() { + val resource = ResourceSpec("tracks", "Tracks", Confidence.verified) + val context = assertNotNull( + nativeAudioCollectionContext( + ResourceSpec("albums", "Albums", Confidence.verified), + NativeRecord("album-42", values = mapOf("name" to "Selected album")), + ), + ) + var called = false + val player = NativeAudioRecordPlayer { _, _, _, _ -> called = true } + + assertTrue(!player.playCollectionIfPossible( + resource, + listOf(NativeRecord("note-1", values = mapOf("title" to "Album notes"))), + context, + )) + assertTrue(!called) + } + + @Test + fun `collection artwork falls back to a playable track only when the parent has no image`() { + val albumResource = ResourceSpec("albums", "Albums", Confidence.verified) + val trackResource = ResourceSpec("tracks", "Tracks", Confidence.verified) + val context = assertNotNull( + nativeAudioCollectionContext( + albumResource, + NativeRecord(id = "album-42", values = mapOf("title" to "Selected album")), + ), + ) + val track = NativeRecord( + id = "track-7", + values = mapOf( + "title" to "First track", + "coverUrl" to "/artwork/track-7.jpg", + ), + ) + + val artwork = nativeAudioCollectionArtworkReference( + collectionContext = context, + childResource = trackResource, + firstPlayableRecord = track, + resolver = null, + ) + + assertEquals("/artwork/track-7.jpg", artwork.relativePath) + assertEquals(NativeMediaArtworkFallback.Track, artwork.fallback) + } + + private fun destination( + resourceId: String, + label: String, + component: NativeComponent = NativeComponent.mediaLibrary, + evidence: List = emptyList(), + ): Pair { + val viewId = "$resourceId-view" + return DynamicNavigationDestination( + layoutId = viewId, + label = label, + resourceId = resourceId, + actionId = "$resourceId-list", + ) to ViewSpec( + id = viewId, + title = label, + resourceId = resourceId, + component = component, + sourceActionId = "$resourceId-list", + confidence = Confidence.verified, + evidence = evidence, + ) + } +}