From 889166c88ab90f1e075bb848d07d6b8378bb9b37 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Tue, 23 Jun 2026 14:56:03 +0200 Subject: [PATCH] Added deep links --- .idea/deploymentTargetSelector.xml | 2 +- .idea/planningMode.xml | 1 + app/src/main/AndroidManifest.xml | 11 ++- .../pianotimeline/MainActivity.kt | 30 ++++++- .../pianotimeline/data/DeepLinkParser.kt | 41 +++++++++ .../pianotimeline/ui/MiniPlayer.kt | 7 +- .../pianotimeline/ui/RecordingItem.kt | 53 ++++++++++- .../pianotimeline/ui/RecordingListScreen.kt | 62 ++++++++++++- .../ui/RecordingListViewModel.kt | 90 +++++++++++++++++++ 9 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/DeepLinkParser.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index c5ea1ad..5992046 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47e91ef..c7c8a18 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ - + @@ -23,6 +24,14 @@ + + + + + + diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt b/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt index e204c45..d526f9a 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt @@ -1,22 +1,48 @@ package com.keylightpiano.pianotimeline +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.activity.viewModels import com.keylightpiano.pianotimeline.ui.RecordingListScreen +import com.keylightpiano.pianotimeline.ui.RecordingListViewModel import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme class MainActivity : ComponentActivity() { + + // Hoist the ViewModel to the Activity so deep-link intents (which arrive here) + // can be forwarded to it. The Compose screen uses this same instance. + private val vm: RecordingListViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + // Cold start: an intent may already carry a deep link. + handleIntent(intent) + setContent { // Force dark mode regardless of system setting PianoTimelineTheme(darkTheme = true) { - RecordingListScreen() + RecordingListScreen(vm = vm) } } } + + // Warm start: app already running, a new link arrives. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + val data: Uri = intent?.data ?: return + if (intent.action == Intent.ACTION_VIEW) { + vm.handleDeepLink(data) + } + } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/DeepLinkParser.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/DeepLinkParser.kt new file mode 100644 index 0000000..415330f --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/DeepLinkParser.kt @@ -0,0 +1,41 @@ +package com.keylightpiano.pianotimeline.data + +import android.net.Uri + +/** + * Parses an incoming deep-link URL that points at a media file living next to + * list.json, e.g.: + * https://timeline.keylightpiano.com/music/22.06.2026 - Sinatra - ... - 2.mp3 + * https://timeline.keylightpiano.com/music/22.06.2026 - Sinatra - ... - 2.mid + * + * We match recordings by their `fileName` (audio) or `midiFileName`, so the + * job here is: pull the last path segment (decoded) and decide whether it's an + * audio or a midi link based on its extension. + */ +object DeepLinkParser { + + enum class LinkType { AUDIO, MIDI } + + data class ParsedLink( + val fileName: String, // decoded last path segment, e.g. "... - 2.mp3" + val type: LinkType + ) + + /** + * Returns a ParsedLink if the URI looks like one of our media links, + * or null if it's something we don't handle. + */ + fun parse(uri: Uri): ParsedLink? { + // Uri.lastPathSegment is automatically percent-decoded. + val segment = uri.lastPathSegment?.trim().orEmpty() + if (segment.isEmpty()) return null + + val lower = segment.lowercase() + val type = when { + lower.endsWith(".mp3") -> LinkType.AUDIO + lower.endsWith(".mid") -> LinkType.MIDI + else -> return null + } + return ParsedLink(fileName = segment, type = type) + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt index 1352948..77a483a 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt @@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -61,7 +64,9 @@ fun MiniPlayer( tonalElevation = 3.dp, shadowElevation = 8.dp ) { - Column { + // Inset the content above the system navigation bar so the controls + // aren't hidden under it (the surface background still fills to the edge). + Column(modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars)) { // While the user is dragging, we hold a local position so the thumb // follows the finger instead of snapping back to the polled playhead. var isDragging by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt index cd6a2bd..c7a069d 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt @@ -1,6 +1,9 @@ package com.keylightpiano.pianotimeline.ui +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,14 +19,19 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,6 +45,7 @@ private val NotesGreen = Color(0xFF25B300) private val NewBadgeRed = Color(0xFFE53935) private val PlayingRed = Color(0xFFFF3D3D) +@OptIn(ExperimentalFoundationApi::class) @Composable fun RecordingItem( recording: Recording, @@ -44,11 +53,21 @@ fun RecordingItem( onPlayClick: () -> Unit, onFavoriteClick: () -> Unit, onNotesClick: (() -> Unit)?, + onShareAudio: () -> Unit, + onShareVisualization: (() -> Unit)?, // null when no MIDI + onCopyLink: () -> Unit, modifier: Modifier = Modifier ) { + var menuExpanded by remember { mutableStateOf(false) } + Box(modifier = modifier.fillMaxWidth()) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, // normal taps handled by inner buttons + onLongClick = { menuExpanded = true } + ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), @@ -165,5 +184,35 @@ fun RecordingItem( drawCircle(color = NewBadgeRed) } } + + // Long-press context menu (extensible — more actions can be added later) + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Share audio") }, + onClick = { + menuExpanded = false + onShareAudio() + } + ) + if (onShareVisualization != null) { + DropdownMenuItem( + text = { Text("Share visualization") }, + onClick = { + menuExpanded = false + onShareVisualization() + } + ) + } + DropdownMenuItem( + text = { Text("Copy link") }, + onClick = { + menuExpanded = false + onCopyLink() + } + ) + } } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt index 100fc60..51f797d 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -76,6 +77,7 @@ fun RecordingListScreen( val playbackState by vm.playbackState.collectAsStateWithLifecycle() val shuffle by vm.shuffle.collectAsStateWithLifecycle() val repeatMode by vm.repeatMode.collectAsStateWithLifecycle() + val context = LocalContext.current val searchQuery by vm.searchQuery.collectAsStateWithLifecycle() val sortMode by vm.sortMode.collectAsStateWithLifecycle() val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle() @@ -119,6 +121,29 @@ fun RecordingListScreen( } } + // Deep link: when the VM requests scrolling to a specific entry, find its + // index in the current visible list and scroll there. + val scrollToId by vm.scrollToId.collectAsStateWithLifecycle() + LaunchedEffect(scrollToId, recordings) { + val id = scrollToId ?: return@LaunchedEffect + val index = recordings.indexOfFirst { it.id == id } + if (index >= 0) { + listState.animateScrollToItem(index) + vm.consumeScrollTarget() + } + } + + // Deep link: a .mid link requests the falling-notes view. That screen doesn't + // exist yet, so for now we just consume the request (the list still scrolls to + // the entry via scrollToId above). Wire navigation here when the view is built. + val openVisualizationFor by vm.openVisualizationFor.collectAsStateWithLifecycle() + LaunchedEffect(openVisualizationFor) { + if (openVisualizationFor != null) { + // TODO: navigate to falling-notes view for openVisualizationFor + vm.consumeVisualizationRequest() + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty() @@ -341,7 +366,16 @@ fun RecordingListScreen( onFavoriteClick = { onFavoriteToggle(recording) }, onNotesClick = if (recording.hasMidi) { { /* TODO: open falling notes */ } - } else null + } else null, + onShareAudio = { + shareLink(context, vm.audioShareUrl(recording)) + }, + onShareVisualization = if (recording.hasMidi) { + { vm.midiShareUrl(recording)?.let { shareLink(context, it) } } + } else null, + onCopyLink = { + copyLink(context, vm.audioShareUrl(recording)) + } ) } } @@ -351,3 +385,29 @@ fun RecordingListScreen( } } } + +// ── Share / clipboard helpers ────────────────────────────────────────── + +/** Fire Android's standard share sheet with a media URL as plain text. */ +private fun shareLink(context: android.content.Context, url: String) { + val sendIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, url) + } + val chooser = android.content.Intent.createChooser(sendIntent, null) + // Needed because we may be starting from a non-Activity context wrapper. + chooser.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) +} + +/** Copy a URL to the clipboard. */ +private fun copyLink(context: android.content.Context, url: String) { + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("link", url)) + // On Android 13+ the system shows its own "copied" confirmation; on older + // versions a Toast is the norm. + if (android.os.Build.VERSION.SDK_INT < 33) { + android.widget.Toast.makeText(context, "Link copied", android.widget.Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt index 7e2328c..83f0593 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.keylightpiano.pianotimeline.data.MusicRepository import com.keylightpiano.pianotimeline.data.SettingsStore import com.keylightpiano.pianotimeline.data.SyncManager +import com.keylightpiano.pianotimeline.data.DeepLinkParser import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlayerManager @@ -51,6 +52,35 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { */ private var shuffleOrder: List = emptyList() + // ── Deep-link signals the UI observes ────────────────────────────── + + /** + * Set to a recording id when an incoming link should make the list scroll + * to that entry. The screen observes this, scrolls, then calls + * consumeScrollTarget() so it fires only once. + */ + private val _scrollToId = MutableStateFlow(null) + val scrollToId: StateFlow = _scrollToId.asStateFlow() + + fun consumeScrollTarget() { _scrollToId.value = null } + + /** + * Set to a recording when a .mid link should open the falling-notes view. + * Null until requested. The screen observes this and navigates, then calls + * consumeVisualizationRequest(). (Visualization screen comes later; for now + * this lets us wire the routing and verify it fires.) + */ + private val _openVisualizationFor = MutableStateFlow(null) + val openVisualizationFor: StateFlow = _openVisualizationFor.asStateFlow() + + fun consumeVisualizationRequest() { _openVisualizationFor.value = null } + + // ── Share URLs (used by the long-press menu) ─────────────────────── + + fun audioShareUrl(recording: Recording): String = repo.audioUrl(recording) + + fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording) + // ── Sync state ───────────────────────────────────────────────────── private val _syncState = MutableStateFlow(SyncState.Syncing) @@ -120,6 +150,14 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { }.getOrDefault(RepeatMode.OFF) } + // When recordings become available, flush any deep link that arrived + // before the list was ready. + viewModelScope.launch { + recordings.collect { + if (pendingLink != null) tryResolvePendingLink() + } + } + viewModelScope.launch { _syncState.value = SyncState.Syncing val result = syncManager.sync() @@ -190,6 +228,58 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { fun stopPlayback() = playerManager.stop() + // ── Deep-link handling ───────────────────────────────────────────── + + /** A link that arrived before the recording list was ready, retried later. */ + private var pendingLink: DeepLinkParser.ParsedLink? = null + + /** + * Handle an incoming app-link URL. If the matching recording is already + * loaded, act immediately; otherwise stash it and resolve once data arrives. + */ + fun handleDeepLink(uri: android.net.Uri) { + val parsed = DeepLinkParser.parse(uri) ?: return + if (!resolveLink(parsed)) { + pendingLink = parsed + } + } + + /** + * Try to act on a parsed link against the current list. + * Returns true if handled, false if the target isn't loaded yet. + */ + private fun resolveLink(parsed: DeepLinkParser.ParsedLink): Boolean { + val list = recordings.value + if (list.isEmpty()) return false + + val match = when (parsed.type) { + DeepLinkParser.LinkType.AUDIO -> + list.firstOrNull { it.fileName == parsed.fileName } + DeepLinkParser.LinkType.MIDI -> + list.firstOrNull { it.midiFileName == parsed.fileName } + } ?: return false // no match in current list (maybe filtered out / removed) + + // Always scroll the list to the entry. + _scrollToId.value = match.id + + when (parsed.type) { + DeepLinkParser.LinkType.AUDIO -> { + if (_shuffle.value) rebuildShuffleOrder(startId = match.id) + playerManager.playOrToggle(match.id, repo.audioUrl(match)) + } + DeepLinkParser.LinkType.MIDI -> { + _openVisualizationFor.value = match + } + } + return true + } + + /** Called internally once recordings load, to flush any pending link. */ + private fun tryResolvePendingLink() { + val p = pendingLink ?: return + if (resolveLink(p)) pendingLink = null + } + /** * Manual "next" button — behaves like a natural track-end advance, * honoring shuffle and repeat-all, but a manual skip should always move on