Added deep links
This commit is contained in:
parent
c5101b3708
commit
889166c88a
9 changed files with 289 additions and 8 deletions
|
|
@ -4,7 +4,7 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-06-23T10:17:32.872144833Z">
|
||||
<DropdownSelection timestamp="2026-06-23T12:53:26.691218732Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<entry key="20260623-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" />
|
||||
<entry key="20260623-132846-a16e70c9-6749-49d9-8ddc-0e2751e51684" value="true" />
|
||||
<entry key="20260623-133006-ee14cdd8-d70c-4923-afa5-55b94086b8f0" value="true" />
|
||||
<entry key="20260623-143300-4ba1ead2-54d8-48f5-8bdf-510cc9b3433b" value="true" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.PianoTimeline"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
|
|
@ -23,6 +24,14 @@
|
|||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="timeline.keylightpiano.com"
|
||||
android:pathPrefix="/music/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<String?>(null)
|
||||
val scrollToId: StateFlow<String?> = _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<Recording?>(null)
|
||||
val openVisualizationFor: StateFlow<Recording?> = _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>(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
|
||||
|
|
|
|||
Loading…
Reference in a new issue