Added deep links

This commit is contained in:
OmniaX-Dev 2026-06-23 14:56:03 +02:00
parent c5101b3708
commit 889166c88a
9 changed files with 289 additions and 8 deletions

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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) }

View file

@ -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()
}
)
}
}
}

View file

@ -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()
}
}

View file

@ -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