diff options
Diffstat (limited to 'app')
10 files changed, 900 insertions, 21 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/EndToEndTest.kt b/app/src/androidTest/java/org/terst/listen/data/EndToEndTest.kt new file mode 100644 index 0000000..5596537 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/data/EndToEndTest.kt @@ -0,0 +1,47 @@ +package org.terst.listen.data + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import android.util.Log + +/** + * Exercises the real Room database and a real feed, which the JVM tests cannot: they run against + * fake DAOs, so Room's own constraints and converters are otherwise never executed. + */ +@RunWith(AndroidJUnit4::class) +class EndToEndTest { + + @Test + fun subscribeAndRefreshAgainstARealFeed() { + runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val graph = Graph.get(context) + + val feed = "https://feeds.megaphone.fm/GLT1412515089" + val result = graph.repository.subscribe(feed) + + result.fold( + onSuccess = { sub -> + val episodes = graph.episodes.getForSubscription(sub.id) + Log.i("E2E", "subscribed: '${sub.title}' with ${episodes.size} episodes") + assertTrue("a real feed should yield episodes", episodes.isNotEmpty()) + + // A second refresh must add nothing: it exercises conditional GET and the + // matcher's identity rules against real data rather than fixtures. + val outcome = graph.repository.refresh(graph.subscriptions.getById(sub.id)!!) + Log.i("E2E", "second refresh: $outcome") + val added = (outcome as? RefreshOutcome.Updated)?.newEpisodes ?: 0 + assertTrue("a repeat refresh must not duplicate episodes, added=$added", added == 0) + + val after = graph.episodes.getForSubscription(sub.id) + assertTrue("episode count changed on a no-op refresh", after.size == episodes.size) + }, + onFailure = { Log.i("E2E", "SKIPPED, feed unreachable: ${it.message}") }, + ) + } + } +} diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt new file mode 100644 index 0000000..cae5edf --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -0,0 +1,93 @@ +package org.terst.listen.data + +import android.content.Context +import okhttp3.OkHttpClient +import org.terst.listen.download.ChaquopyYouTubeSource +import org.terst.listen.download.EpisodeDownloader +import org.terst.listen.feed.FeedFetcher +import org.terst.listen.queue.QueueOrdering +import org.terst.listen.queue.RetentionPolicy +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Wiring. Deliberately plain: a personal app with one of everything does not need a DI + * framework, and a single visible construction site is easier to follow than generated graphs. + */ +object Graph { + + @Volatile private var instance: AppGraph? = null + + fun get(context: Context): AppGraph = instance ?: synchronized(this) { + instance ?: AppGraph(context.applicationContext).also { instance = it } + } +} + +class AppGraph(private val context: Context) { + + private val db by lazy { AppDatabase.get(context) } + + val client: OkHttpClient by lazy { + OkHttpClient.Builder() + // Generous: an episode is tens of megabytes and a phone's connection comes and goes. + .callTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + + val subscriptions: SubscriptionDao get() = db.subscriptions() + val episodes: EpisodeDao get() = db.episodes() + val queueDao: QueueDao get() = db.queue() + + val libraryDir: File by lazy { + File(context.filesDir, "audio").also { it.mkdirs() } + } + + val repository: SubscriptionRepository by lazy { + SubscriptionRepository(subscriptions, episodes, FeedFetcher(client)) + } + + val downloader: EpisodeDownloader by lazy { + EpisodeDownloader(client, episodes, libraryDir, ChaquopyYouTubeSource(context)) + } + + val retention: RetentionPolicy by lazy { + RetentionPolicy(gracePeriodMs = TimeUnit.DAYS.toMillis(1)) + } + + val queue: QueueRepository by lazy { QueueRepository(queueDao) } + + private companion object { + const val CONNECT_TIMEOUT_SECONDS = 30L + const val READ_TIMEOUT_SECONDS = 60L + } +} + +/** Applies QueueOrdering's decisions to storage, keeping positions contiguous. */ +class QueueRepository(private val dao: QueueDao) { + + fun observe() = dao.observeQueue() + + suspend fun append(episodeId: String) = rewrite { QueueOrdering.append(it, episodeId) } + + suspend fun playNext(episodeId: String) = rewrite { QueueOrdering.playNext(it, episodeId) } + + suspend fun remove(episodeId: String) = rewrite { QueueOrdering.remove(it, episodeId) } + + suspend fun move(from: Int, to: Int) = rewrite { QueueOrdering.move(it, from, to) } + + /** Drops the finished episode and reports what plays next. */ + suspend fun advance(): String? { + val current = ids() + val (next, remaining) = QueueOrdering.advance(current) + dao.replaceAll(remaining) + return next + } + + private suspend fun ids() = dao.getEntries().map { it.episodeId } + + private suspend fun rewrite(transform: (List<String>) -> List<String>) { + dao.replaceAll(transform(ids())) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt new file mode 100644 index 0000000..4a5fec2 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt @@ -0,0 +1,93 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.SubscriptionEntity +import java.util.concurrent.TimeUnit + +@Composable +internal fun EpisodeRow( + episode: EpisodeEntity, + onEnqueue: () -> Unit, + onPlayNext: () -> Unit, + onDownload: () -> Unit, + onTogglePlayed: () -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2) + Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onPlayNext) { Text("Next") } + Button(onClick = onEnqueue) { Text("Queue") } + if (episode.downloadState != DownloadState.DOWNLOADED) { + Button(onClick = onDownload, enabled = episode.downloadState != DownloadState.DOWNLOADING) { + Text(if (episode.downloadState == DownloadState.DOWNLOADING) "…" else "Get") + } + } + Button(onClick = onTogglePlayed) { + Text(if (episode.playedAtMs == null) "Played" else "Unplayed") + } + } + } +} + +/** Duration, how far in, and download state in one line. */ +internal fun EpisodeEntity.progressLabel(): String { + val parts = mutableListOf<String>() + durationMs?.let { parts += formatDuration(it) } + if (positionMs > 0) parts += "${formatDuration(positionMs)} in" + if (playedAtMs != null) parts += "played" + when (downloadState) { + DownloadState.DOWNLOADED -> parts += "downloaded" + DownloadState.FAILED -> parts += "download failed" + DownloadState.DOWNLOADING -> parts += "downloading" + else -> Unit + } + return parts.joinToString(" · ") +} + +private const val MINUTES_PER_HOUR = 60 + +private fun formatDuration(ms: Long): String { + val hours = TimeUnit.MILLISECONDS.toHours(ms) + val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) % MINUTES_PER_HOUR + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" +} diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt new file mode 100644 index 0000000..ef1cca8 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt @@ -0,0 +1,181 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.SubscriptionEntity +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibraryScreen( + vm: ListenViewModel, + onOpenSubscription: (String) -> Unit, + onOpenQueue: () -> Unit, +) { + val subscriptions by vm.subscriptions.collectAsState() + val queue by vm.queue.collectAsState() + val refreshing by vm.refreshing.collectAsState() + val banner by vm.banner.collectAsState() + val snackbar = remember { SnackbarHostState() } + var adding by remember { mutableStateOf(false) } + + LaunchedEffect(banner) { + banner?.let { + snackbar.showSnackbar(it.text) + vm.dismissBanner() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Listen") }, + actions = { + IconButton(onClick = { adding = !adding }) { + Icon(Icons.Default.Add, contentDescription = "Add a podcast") + } + IconButton(onClick = vm::refreshAll, enabled = !refreshing) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + if (refreshing) { + CircularProgressIndicator(Modifier.padding(16.dp)) + } + if (adding) { + AddSubscription(onSubmit = { vm.subscribe(it); adding = false }) + } + + LibraryList(subscriptions, queue, onOpenSubscription, onOpenQueue) + } + } +} + +@Composable +private fun LibraryList( + subscriptions: List<SubscriptionEntity>, + queue: List<EpisodeEntity>, + onOpenSubscription: (String) -> Unit, + onOpenQueue: () -> Unit, +) { + LazyColumn(Modifier.fillMaxSize()) { + if (queue.isNotEmpty()) { + item { UpNextCard(queue, onOpenQueue) } + } + if (subscriptions.isEmpty()) { + item { EmptyLibrary() } + } + items(subscriptions, key = { it.id }) { subscription -> + SubscriptionRow(subscription) { onOpenSubscription(subscription.id) } + HorizontalDivider() + } + } +} + +@Composable +private fun UpNextCard(queue: List<EpisodeEntity>, onOpenQueue: () -> Unit) { + Card(Modifier.fillMaxWidth().padding(12.dp).clickable(onClick = onOpenQueue)) { + Column(Modifier.padding(16.dp)) { + Text("Up Next", style = MaterialTheme.typography.titleMedium) + Text( + "${queue.size} episode(s) · ${queue.first().title}", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun EmptyLibrary() { + Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("No subscriptions yet", style = MaterialTheme.typography.titleMedium) + Text( + "Share a podcast link to Listen, paste a feed URL above, or import an OPML file.", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun AddSubscription(onSubmit: (String) -> Unit) { + var text by remember { mutableStateOf("") } + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("Feed, show page, or YouTube channel") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button(onClick = { onSubmit(text) }, enabled = text.isNotBlank()) { Text("Add") } + } +} + +@Composable +private fun SubscriptionRow(subscription: SubscriptionEntity, onClick: () -> Unit) { + Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) { + Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) + subscription.author?.let { + Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + // A feed that has quietly stopped working is otherwise indistinguishable from one with + // nothing new, which is the whole reason the error is stored rather than logged. + subscription.lastError?.let { + Text( + "Last refresh failed: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + ) + } + } +} + diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt new file mode 100644 index 0000000..fa8a89d --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -0,0 +1,137 @@ +package org.terst.listen.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.Graph +import org.terst.listen.data.RefreshOutcome +import org.terst.listen.data.SubscriptionEntity +import org.terst.listen.data.SubscriptionKind +import org.terst.listen.feed.SubscriptionResolver + +/** Transient message for the UI. Errors are surfaced, never only logged. */ +data class Banner(val text: String, val isError: Boolean = false) + +class ListenViewModel(app: Application) : AndroidViewModel(app) { + + private val graph = Graph.get(app) + + val subscriptions: StateFlow<List<SubscriptionEntity>> = + graph.subscriptions.observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + + val queue: StateFlow<List<EpisodeEntity>> = + graph.queue.observe() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + + private val _banner = MutableStateFlow<Banner?>(null) + val banner: StateFlow<Banner?> = _banner.asStateFlow() + + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow() + + fun dismissBanner() { + _banner.value = null + } + + fun episodesOf(subscriptionId: String) = graph.episodes.observeForSubscription(subscriptionId) + + /** + * Adds a subscription from a pasted or shared URL. + * + * Resolution happens here rather than in the share activity so both entry points behave + * identically. + */ + fun subscribe(rawInput: String) { + val url = SubscriptionResolver.extractUrl(rawInput) ?: rawInput.trim() + if (url.isEmpty()) { + _banner.value = Banner("Nothing to subscribe to", isError = true) + return + } + + viewModelScope.launch { + val kind = if (SubscriptionResolver.isYouTube(url)) { + SubscriptionKind.YOUTUBE_CHANNEL + } else { + SubscriptionKind.PODCAST + } + val feedUrl = SubscriptionResolver.youtubeFeedUrl(url) ?: url + + graph.repository.subscribe(feedUrl, kind).fold( + onSuccess = { _banner.value = Banner("Subscribed to ${it.title}") }, + // The reason, not a generic failure: "couldn't subscribe" gives the user nothing + // to act on. + onFailure = { _banner.value = Banner(it.message ?: "Could not subscribe", isError = true) }, + ) + } + } + + fun refreshAll() { + if (_refreshing.value) return + _refreshing.value = true + viewModelScope.launch { + val outcomes = graph.repository.refreshAll() + _refreshing.value = false + + val failures = outcomes.values.filterIsInstance<RefreshOutcome.Failed>() + val added = outcomes.values.filterIsInstance<RefreshOutcome.Updated>().sumOf { it.newEpisodes } + _banner.value = when { + failures.isNotEmpty() -> + Banner("${failures.size} feed(s) failed: ${failures.first().message}", isError = true) + added > 0 -> Banner("$added new episode(s)") + else -> Banner("Up to date") + } + } + } + + fun unsubscribe(id: String) { + viewModelScope.launch { graph.subscriptions.delete(id) } + } + + fun enqueue(episodeId: String) { + viewModelScope.launch { + graph.queue.append(episodeId) + _banner.value = Banner("Added to Up Next") + } + } + + fun playNext(episodeId: String) { + viewModelScope.launch { + graph.queue.playNext(episodeId) + _banner.value = Banner("Playing next") + } + } + + fun removeFromQueue(episodeId: String) { + viewModelScope.launch { graph.queue.remove(episodeId) } + } + + fun download(episode: EpisodeEntity) { + viewModelScope.launch { + val subscription = graph.subscriptions.getById(episode.subscriptionId) + val kind = subscription?.kind ?: SubscriptionKind.PODCAST + graph.downloader.download(episode, kind).onFailure { + _banner.value = Banner("Download failed: ${it.message}", isError = true) + } + } + } + + fun markPlayed(episodeId: String) { + viewModelScope.launch { graph.episodes.markPlayed(episodeId, System.currentTimeMillis()) } + } + + fun markUnplayed(episodeId: String) { + viewModelScope.launch { graph.episodes.markUnplayed(episodeId) } + } + + private companion object { + const val STOP_TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt index 88cd45f..6fd76e5 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -3,20 +3,23 @@ package org.terst.listen.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController class MainActivity : ComponentActivity() { + + private val viewModel: ListenViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { ListenTheme { LibraryPlaceholder() } } + setContent { ListenTheme { ListenApp(viewModel) } } } } @@ -26,9 +29,27 @@ fun ListenTheme(content: @Composable () -> Unit) { } @Composable -private fun LibraryPlaceholder() { - Column(modifier = Modifier.padding(24.dp)) { - Text("Listen", style = MaterialTheme.typography.headlineMedium) - Text("No subscriptions yet.", style = MaterialTheme.typography.bodyMedium) +private fun ListenApp(viewModel: ListenViewModel) { + val nav = rememberNavController() + + NavHost(navController = nav, startDestination = "library") { + composable("library") { + LibraryScreen( + vm = viewModel, + onOpenSubscription = { nav.navigate("subscription/$it") }, + onOpenQueue = { nav.navigate("queue") }, + ) + } + composable("queue") { + QueueScreen(vm = viewModel, onBack = { nav.popBackStack() }) + } + composable("subscription/{id}") { entry -> + val id = entry.arguments?.getString("id") + if (id == null) { + nav.popBackStack() + } else { + SubscriptionScreen(vm = viewModel, subscriptionId = id, onBack = { nav.popBackStack() }) + } + } } } diff --git a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt new file mode 100644 index 0000000..e297489 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt @@ -0,0 +1,75 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.SubscriptionEntity +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QueueScreen(vm: ListenViewModel, onBack: () -> Unit) { + val queue by vm.queue.collectAsState() + Scaffold( + topBar = { + TopAppBar( + title = { Text("Up Next") }, + navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + ) + }, + ) { padding -> + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(queue, key = { it.id }) { episode -> + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) + } + Button(onClick = { vm.removeFromQueue(episode.id) }) { Text("Remove") } + } + HorizontalDivider() + } + } + } +} + diff --git a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt index 4130c2b..2ee9418 100644 --- a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt @@ -4,29 +4,64 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.terst.listen.data.Graph +import org.terst.listen.data.SubscriptionKind +import org.terst.listen.feed.SubscriptionResolver /** - * Share-sheet target. With discovery out of scope, this and OPML import are how subscriptions - * get added: a URL shared from a browser or FeeDreader is resolved to a feed. + * Share-sheet target. * - * Resolution itself is not wired up yet -- see SubscriptionResolver. + * With discovery cut from scope, this and OPML import are how subscriptions get added, so it has + * to handle whatever a browser or FeeDreader hands over: a feed URL, a show's web page, an Apple + * Podcasts link, or a YouTube channel. + * + * Deliberately has no UI. It resolves, reports the outcome in a toast, and closes -- a share is + * a one-shot action, and putting a screen in front of it would be in the way. */ class ShareReceiverActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val shared = intent?.takeIf { it.action == Intent.ACTION_SEND } ?.getStringExtra(Intent.EXTRA_TEXT) .orEmpty() - val url = URL_REGEX.find(shared)?.value - Toast.makeText( - this, - if (url == null) "No link found in that share" else "Resolving $url", - Toast.LENGTH_SHORT, - ).show() - finish() + + val url = SubscriptionResolver.extractUrl(shared) + if (url == null) { + reportAndFinish("No link found in that share") + return + } + + // Toast only -- finishing here would cancel lifecycleScope and take the subscribe with + // it, so the activity stays alive until the network call completes. + toast("Resolving…") + lifecycleScope.launch { + val graph = Graph.get(applicationContext) + val kind = if (SubscriptionResolver.isYouTube(url)) { + SubscriptionKind.YOUTUBE_CHANNEL + } else { + SubscriptionKind.PODCAST + } + val feedUrl = SubscriptionResolver.youtubeFeedUrl(url) ?: url + + graph.repository.subscribe(feedUrl, kind).fold( + onSuccess = { reportAndFinish("Subscribed to ${it.title}") }, + // The actual reason. "Couldn't subscribe" leaves the user with nothing to act on, + // and this is the only feedback a share ever gets. + onFailure = { reportAndFinish(it.message ?: "Could not subscribe") }, + ) + } + } + + private fun toast(message: String) { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() } - private companion object { - val URL_REGEX = Regex("""https?://\S+""") + private fun reportAndFinish(message: String) { + toast(message) + if (!isFinishing) finish() } } diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt new file mode 100644 index 0000000..92568de --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -0,0 +1,76 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.SubscriptionEntity +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubscriptionScreen(vm: ListenViewModel, subscriptionId: String, onBack: () -> Unit) { + val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList()) + val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(subscription?.title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + ) + }, + ) { padding -> + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(episodes, key = { it.id }) { episode -> + EpisodeRow( + episode = episode, + onEnqueue = { vm.enqueue(episode.id) }, + onPlayNext = { vm.playNext(episode.id) }, + onDownload = { vm.download(episode) }, + onTogglePlayed = { + if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id) + }, + ) + HorizontalDivider() + } + } + } +} + diff --git a/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt new file mode 100644 index 0000000..a3ed294 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt @@ -0,0 +1,121 @@ +package org.terst.listen.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * The bridge between QueueOrdering's decisions and storage. + * + * Positions are contiguous ordinals, so the invariant worth testing is that every operation + * leaves them that way -- a gap or a duplicate renders wrongly and reorders unpredictably. + */ +class QueueRepositoryTest { + + private lateinit var dao: FakeQueueDao + private lateinit var repo: QueueRepository + + private fun episode(id: String) = EpisodeEntity( + id = id, + subscriptionId = "s1", + guid = id, + title = "Episode $id", + mediaUrl = "https://cdn/$id.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + ) + + @Before + fun setUp() { + dao = FakeQueueDao() + dao.episodeSource = { listOf("a", "b", "c", "d").map(::episode) } + repo = QueueRepository(dao) + } + + private suspend fun ids() = dao.getEntries().map { it.episodeId } + + private suspend fun assertContiguous() { + val positions = dao.getEntries().map { it.position } + assertEquals("positions must be contiguous from zero", positions.indices.toList(), positions) + } + + @Test + fun `appending builds the queue in order`() = runTest { + repo.append("a") + repo.append("b") + assertEquals(listOf("a", "b"), ids()) + assertContiguous() + } + + @Test + fun `appending something already queued moves it to the end`() = runTest { + listOf("a", "b", "c").forEach { repo.append(it) } + repo.append("a") + assertEquals(listOf("b", "c", "a"), ids()) + assertContiguous() + } + + @Test + fun `play next inserts behind the current episode`() = runTest { + listOf("a", "b", "c").forEach { repo.append(it) } + repo.playNext("d") + assertEquals(listOf("a", "d", "b", "c"), ids()) + assertContiguous() + } + + @Test + fun `removing closes the gap`() = runTest { + listOf("a", "b", "c").forEach { repo.append(it) } + repo.remove("b") + assertEquals(listOf("a", "c"), ids()) + assertContiguous() + } + + @Test + fun `moving reorders and renumbers`() = runTest { + listOf("a", "b", "c").forEach { repo.append(it) } + repo.move(from = 0, to = 2) + assertEquals(listOf("b", "c", "a"), ids()) + assertContiguous() + } + + @Test + fun `advance drops the finished episode and reports the next`() = runTest { + listOf("a", "b", "c").forEach { repo.append(it) } + assertEquals("b", repo.advance()) + assertEquals(listOf("b", "c"), ids()) + assertContiguous() + } + + @Test + fun `advancing the last episode empties the queue`() = runTest { + repo.append("a") + assertNull(repo.advance()) + assertEquals(emptyList<String>(), ids()) + } + + @Test + fun `advancing an empty queue is harmless`() = runTest { + assertNull(repo.advance()) + assertEquals(emptyList<String>(), ids()) + } + + @Test + fun `removing something absent leaves the queue intact`() = runTest { + listOf("a", "b").forEach { repo.append(it) } + repo.remove("zzz") + assertEquals(listOf("a", "b"), ids()) + assertContiguous() + } + + /** A drag can report an index past the end; the queue must survive it unchanged. */ + @Test + fun `an out of range move leaves the queue intact`() = runTest { + listOf("a", "b").forEach { repo.append(it) } + repo.move(from = 0, to = 99) + assertEquals(listOf("a", "b"), ids()) + assertContiguous() + } +} |
