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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" /> <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-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" />
<entry key="20260623-132846-a16e70c9-6749-49d9-8ddc-0e2751e51684" 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-133006-ee14cdd8-d70c-4923-afa5-55b94086b8f0" value="true" />
<entry key="20260623-143300-4ba1ead2-54d8-48f5-8bdf-510cc9b3433b" value="true" />
</map> </map>
</option> </option>
</component> </component>

View file

@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@ -16,6 +16,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.PianoTimeline" android:theme="@style/Theme.PianoTimeline"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
@ -23,6 +24,14 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </application>

View file

@ -1,22 +1,48 @@
package com.keylightpiano.pianotimeline package com.keylightpiano.pianotimeline
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme import androidx.activity.viewModels
import com.keylightpiano.pianotimeline.ui.RecordingListScreen import com.keylightpiano.pianotimeline.ui.RecordingListScreen
import com.keylightpiano.pianotimeline.ui.RecordingListViewModel
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Cold start: an intent may already carry a deep link.
handleIntent(intent)
setContent { setContent {
// Force dark mode regardless of system setting // Force dark mode regardless of system setting
PianoTimelineTheme(darkTheme = true) { 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -61,7 +64,9 @@ fun MiniPlayer(
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 8.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 // While the user is dragging, we hold a local position so the thumb
// follows the finger instead of snapping back to the polled playhead. // follows the finger instead of snapping back to the polled playhead.
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }

View file

@ -1,6 +1,9 @@
package com.keylightpiano.pianotimeline.ui package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.material.icons.outlined.StarBorder
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -37,6 +45,7 @@ private val NotesGreen = Color(0xFF25B300)
private val NewBadgeRed = Color(0xFFE53935) private val NewBadgeRed = Color(0xFFE53935)
private val PlayingRed = Color(0xFFFF3D3D) private val PlayingRed = Color(0xFFFF3D3D)
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RecordingItem( fun RecordingItem(
recording: Recording, recording: Recording,
@ -44,11 +53,21 @@ fun RecordingItem(
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit, onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?, onNotesClick: (() -> Unit)?,
onShareAudio: () -> Unit,
onShareVisualization: (() -> Unit)?, // null when no MIDI
onCopyLink: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var menuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {}, // normal taps handled by inner buttons
onLongClick = { menuExpanded = true }
),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
), ),
@ -165,5 +184,35 @@ fun RecordingItem(
drawCircle(color = NewBadgeRed) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -76,6 +77,7 @@ fun RecordingListScreen(
val playbackState by vm.playbackState.collectAsStateWithLifecycle() val playbackState by vm.playbackState.collectAsStateWithLifecycle()
val shuffle by vm.shuffle.collectAsStateWithLifecycle() val shuffle by vm.shuffle.collectAsStateWithLifecycle()
val repeatMode by vm.repeatMode.collectAsStateWithLifecycle() val repeatMode by vm.repeatMode.collectAsStateWithLifecycle()
val context = LocalContext.current
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle() val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
val sortMode by vm.sortMode.collectAsStateWithLifecycle() val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.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 scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty() val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
@ -341,7 +366,16 @@ fun RecordingListScreen(
onFavoriteClick = { onFavoriteToggle(recording) }, onFavoriteClick = { onFavoriteToggle(recording) },
onNotesClick = if (recording.hasMidi) { onNotesClick = if (recording.hasMidi) {
{ /* TODO: open falling notes */ } { /* 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.MusicRepository
import com.keylightpiano.pianotimeline.data.SettingsStore import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager import com.keylightpiano.pianotimeline.player.PlayerManager
@ -51,6 +52,35 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
*/ */
private var shuffleOrder: List<String> = emptyList() 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 ───────────────────────────────────────────────────── // ── Sync state ─────────────────────────────────────────────────────
private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing) private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
@ -120,6 +150,14 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
}.getOrDefault(RepeatMode.OFF) }.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 { viewModelScope.launch {
_syncState.value = SyncState.Syncing _syncState.value = SyncState.Syncing
val result = syncManager.sync() val result = syncManager.sync()
@ -190,6 +228,58 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
fun stopPlayback() = playerManager.stop() 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, * Manual "next" button behaves like a natural track-end advance,
* honoring shuffle and repeat-all, but a manual skip should always move on * honoring shuffle and repeat-all, but a manual skip should always move on