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>
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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) }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue