diff --git a/app/README.md b/app/README.md index 50faee854..9b90d0be9 100644 --- a/app/README.md +++ b/app/README.md @@ -104,6 +104,8 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr `StartupWorkGate` is process-scoped and releases optional cloud coordinator initialization, periodic sync scheduling, Smart Downloads scheduling/catch-up, foreground subscription sync and legacy RSS repair after the initial screen commits. Optional initialization uses a suspending mutex so ready does not release a burst of initializers. Home signals after loaded local content and two frames; other initial routes release after their first two frames. A headless worker/widget launch releases at the first idle main queue; `MainActivity` identifies a UI launch before creating content. If the first Activity stops before content is ready, background startup also releases; configuration recreation waits for the replacement UI. Recreated roots reuse the same gate. Repeated signals and cancelled initializers are safe (`StartupWorkGateTest`). Adaptive ranking telemetry also waits for readiness. Appearance flows use restored fast-cache values while background hydration fills missing DataStore keys; existing choices are never overwritten. Play update checks begin after the first two Compose frames. Playback/session restoration and preference identity remain owned by the existing composition root. +- Release runtime dependency snapshots include Jsoup through `:core:catalog`, which owns shared show-notes resource and chapter parsing. No direct parser-library dependency is added here. + ## Threading / lifecycle - `AppContainer` is created once from `BoxLoreApplication.onCreate` and is application-scoped. diff --git a/app/dependencies/releaseRuntimeClasspath.txt b/app/dependencies/releaseRuntimeClasspath.txt index ac35695fc..40bc4c2dc 100644 --- a/app/dependencies/releaseRuntimeClasspath.txt +++ b/app/dependencies/releaseRuntimeClasspath.txt @@ -292,6 +292,7 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.7.3 org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 org.jetbrains:annotations:23.0.0 +org.jsoup:jsoup:1.23.2 org.jspecify:jspecify:1.0.0 sh.calvin.reorderable:reorderable-android:2.5.1 sh.calvin.reorderable:reorderable:2.5.1 diff --git a/core/catalog/README.md b/core/catalog/README.md index 7f199ef43..10b4b34b0 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -36,6 +36,9 @@ Failed consent writes keep activation off, and local notification flags prevent - The two-request concurrency cap applies to publisher-feed refreshes. Podcast Index chunk sync for not-ready shows and missing-feed-URL recovery retain their independent six-request limit. +- `shownotes/ShowNotesParser` interprets publisher HTML with Jsoup without fetching pages. It preserves meaningful anchor labels, resolves relative URLs only with a known base, classifies real domain boundaries and routes, filters infrastructure/media links, and deduplicates tracking variants while retaining case-sensitive paths, query values, and fragments. `EpisodeLinkTitles` removes destination-platform labels (including aliases and generic actions) so they cannot override a URL's profile handle. Social handles use platform-specific profile routes; login, sharing, and other reserved routes are not usernames. Description chapters require at least two valid timestamp/title lines; generated seek links never rewrite existing anchors, attributes, code, or prose clock times. `ChapterRepository` delegates description parsing to this shared parser. +- `crosspromo/CrossPromotionDetector` requires explicit feed-exchange wording or corroborated introduction/description evidence; Bonus metadata and missing episode numbering do not establish a promotion. A named introduction can be corroborated by a matching listening/following invitation even in long sentences with weekday schedules, without relying on short duration or Trailer metadata. Self-show comparison uses normalized whole names (including `and`/`&`) rather than substrings. `CrossPromotionResolver` retains the full extracted name, excludes the host, rejects ambiguous matches, and prefers an unambiguous Apple Podcasts destination. A lone unnamed Apple link must resolve to a matching title before it can be used; unrelated links fall back to search. Cancellation propagates and transient search failures remain retryable. Its bounded cache expires empty searches after one minute and successful matches after one hour. + ## Internal structure ```text @@ -58,6 +61,14 @@ src/main/java/cx/aswin/boxlore/core/catalog/ EpisodeMapper.kt SubscriptionRepository.kt ChapterRepository.kt + shownotes/ + ShowNotesParser.kt + EpisodeLinkTitles.kt + DescriptionChapters.kt + EpisodeLinkClassifier.kt + crosspromo/ + CrossPromotionDetector.kt + CrossPromotionResolver.kt TranscriptRepository.kt SharedAppDependencies.kt SubscriptionForegroundSync.kt @@ -89,6 +100,8 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - Libraries: Retrofit, OkHttp, Gson, coroutines, DataStore, Firebase Database, Firebase Messaging, and Install Referrer. - Reverse-edge rule: catalog must not depend on playback, downloads, designsystem, analytics, or feature modules. +- Show-notes HTML parsing uses Jsoup; the library stays inside `:core:catalog` and shared values remain in `:core:model`. + ## Threading / lifecycle - Production repositories are application-scoped through `AppContainer` and `SharedAppDependenciesHolder`. @@ -114,6 +127,8 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ./gradlew :core:catalog:testDebugUnitTest --tests 'cx.aswin.boxlore.core.catalog.PodcastRepositoryCatalogTest' ``` +- `ShowNotesParserTest`, `CrossPromotionDetectorTest`, and `CrossPromotionResolverTest` cover malformed/relative anchors, platform-label/handle separation, reserved profile routes, destination purpose and spoof domains, URL deduplication, safe timestamp linking, chapter boundaries, weekday-scheduled full-length introductions, false promotions and shorter-name prefixes, full-title matching, ambiguous/host matches, verified unnamed Apple links, retry, cancellation, and cache expiry. + ## CI relevance - `unit-tests.yml` runs catalog JVM tests. diff --git a/core/catalog/build.gradle.kts b/core/catalog/build.gradle.kts index bb47c2f3a..cf013e160 100644 --- a/core/catalog/build.gradle.kts +++ b/core/catalog/build.gradle.kts @@ -95,6 +95,7 @@ dependencies { // JSON Streaming implementation(libs.gson) + implementation(libs.jsoup) implementation(libs.okhttp) // Firebase (database and messaging — SubscriptionRepository uses firebase.database + messaging) implementation(platform(libs.firebase.bom)) diff --git a/core/catalog/dependencies/releaseRuntimeClasspath.txt b/core/catalog/dependencies/releaseRuntimeClasspath.txt index ccd61f5da..290372531 100644 --- a/core/catalog/dependencies/releaseRuntimeClasspath.txt +++ b/core/catalog/dependencies/releaseRuntimeClasspath.txt @@ -124,4 +124,5 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3 org.jetbrains:annotations:23.0.0 +org.jsoup:jsoup:1.23.2 org.jspecify:jspecify:1.0.0 diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/ChapterRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/ChapterRepository.kt index 6feefe962..e8077578c 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/ChapterRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/ChapterRepository.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.core.catalog import cx.aswin.boxlore.core.model.Chapter import cx.aswin.boxlore.core.model.Episode import java.net.URL +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -61,6 +62,8 @@ object ChapterRepository { cache[normalizedUrl] = chapters } chapters + } catch (cancelled: CancellationException) { + throw cancelled } catch (e: Exception) { android.util.Log.w("ChapterRepo", "Failed to fetch chapters: $normalizedUrl", e) emptyList() @@ -141,72 +144,8 @@ object ChapterRepository { * Parses chapter timestamps from the episode description. * Supports both hh:mm:ss and mm:ss formats, and detects timestamps at either start or end of lines. */ - fun parseChaptersFromDescription(htmlDescription: String?): List { - if (htmlDescription.isNullOrEmpty()) return emptyList() - - try { - // Replace HTML paragraph, line break, and list tags with newlines - val cleanText = htmlDescription - .replace("(?i)

".toRegex(), "\n") - .replace("(?i)

".toRegex(), "\n") - .replace("(?i)".toRegex(), "\n") - .replace("(?i)
  • ".toRegex(), "\n") - .replace("<[^>]+>".toRegex(), "") // Strip all other HTML tags - - val lines = cleanText.split("\n") - val chapters = mutableListOf() - - // Regex to find timestamps (e.g. 12:34 or 1:12:34) - val timestampRegex = """\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b""".toRegex() - - for (line in lines) { - val trimmed = line.trim() - val match = timestampRegex.find(trimmed) ?: continue - - val hours = match.groups[1]?.value?.toIntOrNull() ?: 0 - val minutes = match.groups[2]?.value?.toIntOrNull() ?: 0 - val seconds = match.groups[3]?.value?.toIntOrNull() ?: 0 - - if (minutes >= 60 || seconds >= 60) continue - - // Determine whether the title is before or after the timestamp - val matchStart = match.range.first - val matchEnd = match.range.last + 1 - - val rawTitle = if (matchStart > trimmed.length - matchEnd) { - trimmed.substring(0, matchStart) - } else { - trimmed.substring(matchEnd) - } - - // Clean up any remaining braces, hyphens, colons, or whitespace around the title - val cleanTitle = rawTitle.trim() - .replace("^[^\\p{L}\\p{N}]+".toRegex(), "") // Strip leading non-alphanumeric chars - .replace("[^\\p{L}\\p{N}]+$".toRegex(), "") // Strip trailing non-alphanumeric chars - .trim() - - if (cleanTitle.isNotEmpty()) { - val startTime = hours * 3600.0 + minutes * 60.0 + seconds.toDouble() - chapters.add( - Chapter( - startTime = startTime, - title = cleanTitle - ) - ) - } - } - - // Safeguard: only return if we find at least 2 valid chapters (avoid random standalone timestamp false positives) - return if (chapters.size >= 2) { - chapters.sortedBy { it.startTime } - } else { - emptyList() - } - } catch (e: Exception) { - android.util.Log.w("ChapterRepo", "Failed to parse chapters from description", e) - return emptyList() - } - } + fun parseChaptersFromDescription(htmlDescription: String?): List = + cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser.parse(htmlDescription).chapters private fun JSONObject.optStringOrNull(name: String, fallback: String? = null): String? { if (isNull(name)) return fallback diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetector.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetector.kt index 87671d6a3..3e0abb08f 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetector.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetector.kt @@ -1,244 +1,109 @@ package cx.aswin.boxlore.core.catalog.crosspromo +import cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser import cx.aswin.boxlore.core.model.CrossPromotionConfidence import cx.aswin.boxlore.core.model.CrossPromotionIndicator import cx.aswin.boxlore.core.model.CrossPromotionResult import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import cx.aswin.boxlore.core.model.ShowNotes +import java.util.Locale class CrossPromotionDetector { - - private val strictDelimiterRegex = Regex( - """^(?:\[|\(|\*)?(?:feed drop|trailer swap|promo drop|bonus drop|listen now|feed swap|feed share|promo swap|special preview|listen to|guest feed|companion show|network premiere|crossover|cross.?promo|promo episode)(?:\]|\)|\*)?\s*(?::|-|\|\||\|)\s*(.+)""", - RegexOption.IGNORE_CASE - ) - - private val conditionalDelimiterRegex = Regex( - """^(?:\[|\(|\*)?(?:introducing|sneak peek|discover|meet|check out|we recommend|announcing|new season|brand new season|next season|next seaton|new sesson|brand new sesson|next sesson|sesson|try|sample|preview)(?:\]|\)|\*)?\s*(?::|-|\|\||\|)\s*(.+)""", - RegexOption.IGNORE_CASE - ) - - private val presentsRegex = Regex( - """^.*(?:presents|presenting|presented by|from the creators of|from the makers of|from the team behind|brought to you by)(?:\s+[^:\-|]+)?\s*(?::|-|\|\||\|)\s*(.+)""", - RegexOption.IGNORE_CASE - ) - - private val seamlessIntroducingRegex = Regex( - """^(?:\[|\(|\*)?introducing(?:\]|\)|\*)?\s+(?!season\s)([^:\-|].+)""", - RegexOption.IGNORE_CASE - ) - - /** "Subscribe to X", "Listen to our new show X", "Check out the podcast X" in description. */ - private val descriptionSubscribeRegex = Regex( - """(?:subscribe\s+to|listen\s+to(?:\s+our)?(?:\s+new)?(?:\s+show|podcast)?|check\s+out(?:\s+our)?(?:\s+new)?(?:\s+show|podcast)?|find\s+us\s+on|search\s+for|follow)\s+["'“‘]?([^"'”’.!?\n,]{3,80})["'”’]?""", - RegexOption.IGNORE_CASE - ) - - /** Quoted show titles near promo language. */ - private val descriptionQuotedShowRegex = Regex( - """(?:podcast|show|series|feed)\s+["'“‘]([^"'”’]{3,80})["'”’]""", - RegexOption.IGNORE_CASE - ) - - /** Apple Podcasts / Spotify / podcast: deep links with a nearby title hint. */ - private val descriptionLinkTitleRegex = Regex( - """(?:podcasts\.apple\.com|open\.spotify\.com/show|podcasts?:)[^\s<"']+""", - RegexOption.IGNORE_CASE - ) - - private val seasonOnlyNameRegex = Regex( - """^(?:brand\s+)?(?:new|next)\s+season(?:\s+\d+)?$|^season\s+\d+$|^s\d+$""", - RegexOption.IGNORE_CASE - ) - - fun detect(episode: Episode, hostPodcastTitle: String): CrossPromotionResult { + private val strict = Regex("""^(?:\[|\(|\*)?(feed drop|trailer swap|promo drop|bonus drop|feed swap|feed share|promo swap|guest feed|companion show|network premiere|cross.?promo|promo episode)(?:\]|\)|\*)?\s*[:–—|\-]+\s*(.+)$""", RegexOption.IGNORE_CASE) + private val introducing = Regex("""^(?:\[|\(|\*)?(introducing|listen now|listen to|special preview|sneak peek|we recommend)(?:\]|\)|\*)?\s*[:–—|\-]+\s*(.+)$""", RegexOption.IGNORE_CASE) + private val weak = Regex("""^(?:discover|meet|check out|announcing|try|sample|preview|(?:brand )?(?:new|next) (?:season|sesson|seaton)|sesson)\s*[:–—|\-]+\s*(.+)$""", RegexOption.IGNORE_CASE) + private val presents = Regex("""^(?:.+\s)?(?:presents|presenting|presented by|from the creators of|from the makers of|from the team behind|brought to you by)(?:\s+[^:|]+)?\s*[:|]\s*(.+)$""", RegexOption.IGNORE_CASE) + private val seamless = Regex("""^introducing\s+(?!season\b)(.+)$""", RegexOption.IGNORE_CASE) + private val promoLanguage = Regex("""\b(?:subscribe to|listen to|check out|follow|introducing|new podcast|new show|feed drop|trailer swap)\b""", RegexOption.IGNORE_CASE) + private val recommendation = Regex("""\b(?:subscribe\s+to|listen\s+to|check\s+out|follow|introducing)\s+""", RegexOption.IGNORE_CASE) + private val showPrefix = Regex("""^(?:our new podcast|our podcast|the podcast|new podcast|our new show|our show|the show|new show)\s+""", RegexOption.IGNORE_CASE) + private val continuation = Regex("""^\s+(?:wherever|where you|on|for|every|daily|weekly|now|today|as)\b""", RegexOption.IGNORE_CASE) + private val quoted = Regex("""(?:subscribe\s+to|listen\s+to|check\s+out|follow|introducing)(?:\s+(?:our|the|new|podcast|show|series))*\s+["“‘']([^"”’']{3,120})["”’']""", RegexOption.IGNORE_CASE) + private val named = Regex("""(?:subscribe to|listen to|check out|follow)(?:\s+(?:our new podcast|our podcast|the podcast|our new show|our show))*\s+(.{3,120}?)(?=\s+(?:wherever|where you|on Apple|on Spotify|for more|for new|every\s+(?:week|day|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b)|[!\n]|$)""", RegexOption.IGNORE_CASE) + private val seasonOnly = Regex("""(?i)^(?:(?:brand )?(?:new|next) )?(?:season|series|episode|part|s)\s*\d*$""") + + private data class TitleCandidate(val name: String, val indicator: CrossPromotionIndicator, val weak: Boolean = false) + private data class Evidence(val title: TitleCandidate?, val descriptionSupports: Boolean, val short: Boolean, val trailer: Boolean, val bonus: Boolean, val previewTitle: Boolean) + + fun detect(episode: Episode, hostPodcastTitle: String, notes: ShowNotes = ShowNotesParser.parse(episode.description)): CrossPromotionResult { val title = episode.title.trim() - val duration = episode.duration - val episodeType = episode.episodeType?.lowercase() - val episodeNumber = episode.episodeNumber - val plainDescription = stripHtml(episode.description) - - val matchedIndicators = mutableListOf() - var extractedShowName: String? = null - - // 1. Strict indicator: Feed Drop / Trailer Swap / Promo Drop / Bonus Drop / Listen Now + delimiter - val strictMatch = strictDelimiterRegex.find(title) - if (strictMatch != null) { - val name = cleanExtractedName(strictMatch.groupValues[1]) - if (isPromotableShowName(name, hostPodcastTitle)) { - matchedIndicators.add(CrossPromotionIndicator.TITLE_DELIMITER_PATTERN) - return CrossPromotionResult( - isCrossPromotion = true, - confidence = CrossPromotionConfidence.HIGH, - extractedShowName = name, - matchedIndicators = matchedIndicators - ) - } - } - - // 2. Delimiter indicators: Introducing / Sneak Peek / Presents / Presenting + delimiter - val delimiterMatch = conditionalDelimiterRegex.find(title) ?: presentsRegex.find(title) - if (delimiterMatch != null) { - val name = cleanExtractedName(delimiterMatch.groupValues[1]) - if (isPromotableShowName(name, hostPodcastTitle)) { - matchedIndicators.add( - if (presentsRegex.containsMatchIn(title)) { - CrossPromotionIndicator.TITLE_PRESENTS_PATTERN - } else { - CrossPromotionIndicator.TITLE_DELIMITER_PATTERN - } - ) - return CrossPromotionResult( - isCrossPromotion = true, - confidence = CrossPromotionConfidence.HIGH, - extractedShowName = name, - matchedIndicators = matchedIndicators - ) - } - } - - // 3. Optional indicators - val seamlessMatch = seamlessIntroducingRegex.find(title) - if (seamlessMatch != null) { - val name = cleanExtractedName(seamlessMatch.groupValues[1]) - if (isPromotableShowName(name, hostPodcastTitle)) { - extractedShowName = name - matchedIndicators.add(CrossPromotionIndicator.TITLE_SEAMLESS_INTRODUCING) - } - } - - // Description-based name extraction (supports trailer/promo episodes without title cues) - val descriptionName = extractShowNameFromDescription(plainDescription, hostPodcastTitle) - if (descriptionName != null) { - if (extractedShowName == null) { - extractedShowName = descriptionName - } - matchedIndicators.add(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) - } else if (descriptionLinkTitleRegex.containsMatchIn(plainDescription) && - (duration in 30..180 || episodeType == "trailer" || episodeType == "bonus") - ) { - // Promo-shaped episode with platform links but no parseable name — still score the signal. - matchedIndicators.add(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) + strict.matchEntire(title)?.let { + return result(it.groupValues[2], hostPodcastTitle, CrossPromotionConfidence.HIGH, CrossPromotionIndicator.TITLE_DELIMITER_PATTERN) } - - if (duration in 30..180) { - matchedIndicators.add(CrossPromotionIndicator.SHORT_DURATION) - } - - if (episodeType == "trailer" || episodeType == "bonus") { - matchedIndicators.add(CrossPromotionIndicator.TRAILER_OR_BONUS_TYPE) - } - - if (episodeNumber == null) { - matchedIndicators.add(CrossPromotionIndicator.MISSING_EPISODE_NUMBER) - } - - // Optional path: ≥2 signals + a resolvable show name - if (matchedIndicators.size >= 2 && extractedShowName != null) { - val confidence = when { - matchedIndicators.contains(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) && - matchedIndicators.contains(CrossPromotionIndicator.TRAILER_OR_BONUS_TYPE) -> - CrossPromotionConfidence.HIGH - matchedIndicators.size >= 3 -> CrossPromotionConfidence.HIGH - else -> CrossPromotionConfidence.MEDIUM - } - return CrossPromotionResult( - isCrossPromotion = true, - confidence = confidence, - extractedShowName = extractedShowName, - matchedIndicators = matchedIndicators - ) - } - - // High-confidence description alone (explicit subscribe/check-out with a clear name) - if (descriptionName != null && - matchedIndicators.contains(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) && - (duration in 30..300 || episodeType == "trailer" || episodeType == "bonus" || episodeNumber == null) - ) { - return CrossPromotionResult( - isCrossPromotion = true, - confidence = CrossPromotionConfidence.MEDIUM, - extractedShowName = descriptionName, - matchedIndicators = matchedIndicators.ifEmpty { - listOf(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) - } - ) - } - - return CrossPromotionResult( - isCrossPromotion = false, - confidence = CrossPromotionConfidence.NONE, - extractedShowName = null, - matchedIndicators = emptyList() + val descriptionName = descriptionName(notes, hostPodcastTitle) + val candidate = titleCandidate(title)?.takeIf { isOtherShow(it.name, hostPodcastTitle) } + val name = candidate?.name ?: descriptionName ?: return none() + val evidence = Evidence( + candidate, + descriptionName?.let { sameShow(it, name) } == true || candidate?.let { descriptionPromotes(notes, it.name) } == true, + episode.duration in 30..300, + episode.episodeType.equals("trailer", true), + episode.episodeType.equals("bonus", true), + Regex("(?i)\\b(?:preview|trailer|introducing|feed drop)\\b").containsMatchIn(title), ) + if (!supported(evidence)) return none() + val confidence = if (evidence.descriptionSupports && (evidence.trailer || candidate != null)) CrossPromotionConfidence.HIGH else CrossPromotionConfidence.MEDIUM + return CrossPromotionResult(true, confidence, name, indicators(evidence)) } - private fun extractShowNameFromDescription(description: String, hostPodcastTitle: String): String? { - if (description.isBlank()) return null - - descriptionSubscribeRegex.findAll(description).forEach { match -> - val name = cleanExtractedName(match.groupValues[1]) - if (isPromotableShowName(name, hostPodcastTitle)) return name - } + private fun descriptionName(notes: ShowNotes, host: String): String? = notes.links.firstNotNullOfOrNull { link -> + link.title?.takeIf { link.kind == EpisodeLinkKind.PODCAST && promoLanguage.containsMatchIn(link.context) && isOtherShow(it, host) } + } ?: notes.plainText.lines().firstNotNullOfOrNull { line -> + val name = quoted.find(line)?.groupValues?.get(1) ?: named.find(line)?.groupValues?.get(1) + name?.let(::clean)?.takeIf { isOtherShow(it, host) } + } - descriptionQuotedShowRegex.findAll(description).forEach { match -> - val name = cleanExtractedName(match.groupValues[1]) - if (isPromotableShowName(name, hostPodcastTitle)) return name + private fun descriptionPromotes(notes: ShowNotes, name: String): Boolean { + val words = normalizedName(name).split(' ').filter(String::isNotBlank) + if (words.isEmpty()) return false + val pattern = words.joinToString("[^\\p{L}\\p{N}]+") { if (it == "and") "(?:and|&)" else Regex.escape(it) } + val wholeName = Regex("""^["“‘']?$pattern(?=$|[^\p{L}\p{N}])""", RegexOption.IGNORE_CASE) + return notes.plainText.lines().any { line -> + recommendation.findAll(line).any phrase@{ phrase -> + val original = line.substring(phrase.range.last + 1) + val tail = original.takeIf { wholeName.containsMatchIn(it) } ?: original.replaceFirst(showPrefix, "") + val match = wholeName.find(tail) ?: return@phrase false + val rest = tail.substring(match.range.last + 1).trimStart('"', '”', '’', '\'') + rest.isBlank() || rest.trimStart().firstOrNull() in listOf('.', '!', '?', ',', ';', ':') || continuation.containsMatchIn(rest) + } } - - return null } - private fun cleanExtractedName(raw: String): String = raw - .trim() - .trim('"', '\'', '“', '”', '‘', '’', '.', ',', '!', '?') - .replace(Regex("""\s+"""), " ") - .trim() + private fun titleCandidate(title: String): TitleCandidate? { + introducing.matchEntire(title)?.let { return TitleCandidate(clean(it.groupValues[2]), CrossPromotionIndicator.TITLE_DELIMITER_PATTERN) } + weak.matchEntire(title)?.let { return TitleCandidate(clean(it.groupValues[1]), CrossPromotionIndicator.TITLE_DELIMITER_PATTERN, weak = true) } + presents.matchEntire(title)?.let { return TitleCandidate(clean(it.groupValues[1]), CrossPromotionIndicator.TITLE_PRESENTS_PATTERN) } + return seamless.matchEntire(title)?.let { TitleCandidate(clean(it.groupValues[1]), CrossPromotionIndicator.TITLE_SEAMLESS_INTRODUCING) } + } - private fun isPromotableShowName(extractedName: String, hostPodcastTitle: String): Boolean { - if (extractedName.length < 3) return false - if (seasonOnlyNameRegex.matches(extractedName)) return false - if (isSamePodcast(extractedName, hostPodcastTitle)) return false - // Reject generic filler that isn't a show title - val lower = extractedName.lowercase() - if (lower in GENERIC_SHOW_NAMES) return false - return true + private fun supported(evidence: Evidence): Boolean { + // Missing numbering and a Bonus tag are common metadata, not promotion evidence. + if (evidence.title?.weak == true) return evidence.descriptionSupports + val corroborated = evidence.trailer || evidence.short || evidence.descriptionSupports + return evidence.title != null && corroborated || evidence.descriptionSupports && (evidence.trailer || evidence.short && evidence.previewTitle) } - private fun isSamePodcast(extractedName: String, hostPodcastTitle: String): Boolean { - val cleanExtracted = extractedName.trim().lowercase() - val cleanHost = hostPodcastTitle.trim().lowercase() - if (cleanExtracted.isEmpty() || cleanHost.isEmpty()) return false - return cleanHost.contains(cleanExtracted) || cleanExtracted.contains(cleanHost) + private fun indicators(evidence: Evidence): List = buildList { + evidence.title?.let { add(it.indicator) } + if (evidence.descriptionSupports) add(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE) + if (evidence.short) add(CrossPromotionIndicator.SHORT_DURATION) + if (evidence.trailer || evidence.bonus) add(CrossPromotionIndicator.TRAILER_OR_BONUS_TYPE) } - private fun stripHtml(html: String): String { - if (html.isBlank()) return "" - return html - .replace(Regex("""<(?i)br\s*/?>"""), "\n") - .replace(Regex(""""""), "\n") - .replace(Regex("""<[^>]+>"""), " ") - .replace(" ", " ") - .replace("&", "&") - .replace(""", "\"") - .replace("'", "'") - .replace(Regex("""\s+"""), " ") - .trim() + private fun result(name: String, host: String, confidence: CrossPromotionConfidence, indicator: CrossPromotionIndicator): CrossPromotionResult { + val cleanName = clean(name) + return if (isOtherShow(cleanName, host)) CrossPromotionResult(true, confidence, cleanName, listOf(indicator)) else none() } - private companion object { - val GENERIC_SHOW_NAMES = setOf( - "this podcast", - "our podcast", - "the podcast", - "this show", - "our show", - "the show", - "us", - "me", - "more", - "apple podcasts", - "spotify", - "youtube" - ) + private fun isOtherShow(name: String, host: String): Boolean = name.length in 3..120 && !seasonOnly.matches(name) && !sameShow(name, host) && !Regex("(?i)^(?:us|me|our|the show|this show|our show|this podcast|our podcast)(?:\\s|$)").containsMatchIn(name) && !name.contains("://") + private fun clean(name: String): String = name.trim().trim('"', '\'', '“', '”', '‘', '’', '*').replace(Regex("\\s+"), " ") + private fun none() = CrossPromotionResult(false, CrossPromotionConfidence.NONE, null, emptyList()) + + companion object { + internal fun normalizedName(text: String): String = text.lowercase(Locale.ROOT) + .replace("&", " and ") + .replace(Regex("[^\\p{L}\\p{N}]+"), " ").trim().removeSuffix(" podcast").trim() + internal fun sameShow(first: String, second: String): Boolean = normalizedName(first) == normalizedName(second) } } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolver.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolver.kt index 36c0c9339..9d81dbbf9 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolver.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolver.kt @@ -1,113 +1,76 @@ package cx.aswin.boxlore.core.catalog.crosspromo import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind import cx.aswin.boxlore.core.model.Podcast - -class CrossPromotionResolver(private val podcastRepository: PodcastRepository) { - private val resolutionCache = mutableMapOf() - private val cacheLock = Any() - - suspend fun resolve(extractedName: String): Podcast? { - if (extractedName.isBlank()) return null - val cleanExtracted = extractedName.trim().lowercase() - - synchronized(cacheLock) { - if (resolutionCache.containsKey(cleanExtracted)) { - return resolutionCache[cleanExtracted] - } - } - - // 1. Try to extract quoted text if present (e.g. 'History Daily') - val quotedMatch = quotedTextRegex.find(extractedName) - var cleanedName = if (quotedMatch != null) { - quotedMatch.groupValues[1].trim() - } else { - extractedName.trim() - } - - // 2. Remove noise suffixes (e.g. "from host Lindsay Graham", "from Wondery") - cleanedName = noiseSuffixRegex.replace(cleanedName, "").trim() - - // 3. Remove subtitle/season suffixes after colons (e.g. "Dr. Death: The Cowboy" -> "Dr. Death") - if (cleanedName.contains(":")) { - cleanedName = cleanedName.substringBefore(":").trim() - } - - // 4. Clean query by removing season/series/part suffixes for better search indexing - val searchQuery = seasonSuffixRegex.replace(cleanedName, "").trim() - if (searchQuery.isBlank()) { - synchronized(cacheLock) { - resolutionCache[cleanExtracted] = null +import kotlinx.coroutines.CancellationException + +class CrossPromotionResolver internal constructor( + private val search: suspend (String) -> List, + private val lookup: suspend (String) -> Podcast?, + private val now: () -> Long = System::currentTimeMillis, +) { + constructor(repository: PodcastRepository) : this(repository::searchPodcasts, repository::getPodcastDetails) + private data class Cached(val podcast: Podcast?, val expiresAt: Long) + private val cache = linkedMapOf() + + suspend fun resolve(extractedName: String, hostPodcastId: String? = null, links: List = emptyList()): Podcast? { + val name = extractedName.trim().trim('"', '“', '”') + if (name.isBlank()) return null + val podcastLinks = links.filter { it.kind == EpisodeLinkKind.PODCAST } + val namedLinks = podcastLinks.filter { it.title?.let { title -> CrossPromotionDetector.sameShow(title, name) } == true } + val contextualLinks = podcastLinks.filter { it.title == null && CrossPromotionDetector.normalizedName(it.context).contains(CrossPromotionDetector.normalizedName(name)) } + // A lone unnamed destination is usable only after its resolved title matches the promoted show. + val unnamedLink = podcastLinks.singleOrNull()?.takeIf { it.title == null } + val targetLinks = namedLinks.ifEmpty { contextualLinks.takeIf { it.size == 1 } ?: listOfNotNull(unnamedLink) } + val key = "${CrossPromotionDetector.normalizedName(name)}|$hostPodcastId|${targetLinks.joinToString { it.url }}" + synchronized(cache) { cache[key]?.takeIf { it.expiresAt > now() }?.let { return it.podcast } } + val result = try { + val appleIds = targetLinks.filter { it.platform == "Apple Podcasts" } + .mapNotNull { Regex("/id(\\d+)").find(it.url)?.groupValues?.get(1) }.distinct() + val direct = appleIds.singleOrNull()?.let { id -> lookupSafely("itunes:$id") }?.takeIf { + it.id != hostPodcastId && (namedLinks.isNotEmpty() || bestMatch(listOf(it), name, hostPodcastId) != null) } - return null - } - - val resolved = try { - val results = podcastRepository.searchPodcasts(searchQuery) - pickBestMatch(results, cleanedName, searchQuery) + direct ?: bestMatch(search(name), name, hostPodcastId) + } catch (cancelled: CancellationException) { + throw cancelled } catch (_: Exception) { - null + return null // Transient failures remain retryable. } - - synchronized(cacheLock) { - resolutionCache[cleanExtracted] = resolved + synchronized(cache) { + cache.entries.removeAll { it.value.expiresAt <= now() } + cache[key] = Cached(result, now() + if (result == null) 60_000L else 3_600_000L) + while (cache.size > 50) cache.remove(cache.keys.first()) } - return resolved + return result } - private fun pickBestMatch(results: List, cleanedName: String, searchQuery: String): Podcast? { - if (results.isEmpty()) return null - - val normalizedCleaned = normalizeForComparison(cleanedName) - val normalizedQuery = normalizeForComparison(searchQuery) - - data class Scored(val podcast: Podcast, val score: Int) + private suspend fun lookupSafely(id: String): Podcast? = try { + lookup(id) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null // Search can still resolve the explicitly named show. + } - val scored = results.take(8).mapNotNull { podcast -> - val title = normalizeForComparison(podcast.title) + internal fun bestMatch(results: List, name: String, hostId: String?): Podcast? { + val query = CrossPromotionDetector.normalizedName(name) + val queryTokens = query.split(' ').toSet() + val scored = results.distinctBy { it.id }.filter { it.id != hostId }.mapNotNull { podcast -> + val title = CrossPromotionDetector.normalizedName(podcast.title) + val titleTokens = title.split(' ').toSet() + val shared = queryTokens.intersect(titleTokens).size.toFloat() val score = when { - title == normalizedCleaned || title == normalizedQuery -> 100 - title.startsWith(normalizedCleaned) || normalizedCleaned.startsWith(title) -> 85 - title.contains(normalizedCleaned) || normalizedCleaned.contains(title) -> 70 - title.startsWith(normalizedQuery) || normalizedQuery.startsWith(title) -> 60 - title.contains(normalizedQuery) || normalizedQuery.contains(title) -> 50 - // Token overlap for multi-word shows - else -> { - val titleTokens = title.split(' ').filter { it.length > 2 }.toSet() - val queryTokens = normalizedCleaned.split(' ').filter { it.length > 2 }.toSet() - if (queryTokens.isEmpty() || titleTokens.isEmpty()) return@mapNotNull null - val overlap = queryTokens.intersect(titleTokens).size.toFloat() / queryTokens.size - if (overlap >= 0.75f) (40 + (overlap * 20).toInt()) else return@mapNotNull null - } + query == title -> 100 + queryTokens.size >= 2 && titleTokens.size >= 2 && (name.startsWith("${podcast.title}:", true) || podcast.title.startsWith("$name:", true)) -> 80 + queryTokens.size >= 2 && shared / queryTokens.size >= .8f && shared / titleTokens.size >= .8f -> 75 + else -> return@mapNotNull null } - Scored(podcast, score) - } - - return scored.maxByOrNull { it.score }?.takeIf { it.score >= 50 }?.podcast + podcast to score + }.sortedByDescending { it.second } + val best = scored.firstOrNull() ?: return null + if (scored.getOrNull(1)?.let { best.second - it.second < 15 } == true) return null + return best.first } - - private val quotedTextRegex = Regex( - """['"‘“]([^'"’”]+)[''’”]""" - ) - - private val noiseSuffixRegex = Regex( - """\s+(?:from\s+host|hosted\s+by|from|with|by)\b.+""", - RegexOption.IGNORE_CASE - ) - - private val seasonSuffixRegex = Regex( - """\s+(?:brand\s+new\s+season|brand\s+new\s+sesson|brand\s+new|new\s+season|new\s+sesson|next\s+seaton|next\s+sesson|next\s+season|season|sesson|seaton|series|s|part)\b(?:\s*\d+.*)?$""", - RegexOption.IGNORE_CASE - ) - - private fun normalizeForComparison(text: String): String = text.lowercase() - .replace("’", "'") - .replace("‘", "'") - .replace("“", "\"") - .replace("”", "\"") - .replace(":", "") - .replace("-", "") - .replace(",", "") - .replace(Regex("""\s+"""), " ") - .trim() } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/DescriptionChapters.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/DescriptionChapters.kt new file mode 100644 index 000000000..f3d54e284 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/DescriptionChapters.kt @@ -0,0 +1,85 @@ +package cx.aswin.boxlore.core.catalog.shownotes + +import cx.aswin.boxlore.core.model.Chapter +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + +internal object DescriptionChapters { + private val timestamp = Regex("""(? { + val chapters = plain.lines().mapNotNull { parseLine(it, durationSeconds) } + .distinctBy { it.startTime }.sortedBy { it.startTime } + return chapters.takeIf { it.size >= 2 }.orEmpty() + } + + private fun parseLine(line: String, durationSeconds: Int = 0): Chapter? { + val match = timestamp.find(line) ?: return null + val before = line.substring(0, match.range.first) + val after = line.substring(match.range.last + 1) + val rawTitle = when { + before.isBlank() || separators.matches(before) -> after + after.isBlank() || separators.matches(after) -> before + else -> return null + } + val time = seconds(match) ?: return null + val title = rawTitle.replace(trimTitle, "").trim() + if (title.isBlank() || (durationSeconds > 0 && time >= durationSeconds)) return null + return Chapter(startTime = time.toDouble(), title = title) + } + + private fun seconds(match: MatchResult): Long? { + val hours = match.groups[1]?.value?.toLongOrNull() + val minutes = match.groupValues[2].toLongOrNull() ?: return null + val seconds = match.groupValues[3].toLongOrNull() ?: return null + if (seconds >= 60 || (hours != null && minutes >= 60)) return null + return (hours ?: 0L) * 3600L + minutes * 60L + seconds + } + + fun linkTimestamps(body: Element, chapters: List) { + if (chapters.isEmpty()) return + val valid = chapters.associateBy { it.startTime.toLong() } + val textNodes = mutableListOf() + NodeTraversor.traverse( + object : NodeVisitor { + override fun head(node: Node, depth: Int) { + if (node is TextNode && node.noteAncestors().none { it.normalName() in setOf("a", "code", "pre") }) textNodes.add(node) + } + override fun tail(node: Node, depth: Int) = Unit + }, + body + ) + textNodes.forEach { node -> + val text = node.wholeText + val matches = timestamp.findAll(text).filter { match -> + val chapter = valid[seconds(match)] ?: return@filter false + val lineStart = text.lastIndexOf('\n', match.range.first).let { if (it < 0) 0 else it + 1 } + val lineEnd = text.indexOf('\n', match.range.last + 1).let { if (it < 0) text.length else it } + val line = text.substring(lineStart, lineEnd) + val parsed = parseLine(line) + if (parsed != null) { + parsed == chapter + } else if (line.trim() == match.value) { + node.noteAncestors().firstOrNull { it.normalName() in setOf("p", "li", "div") } + ?.wholeText()?.lines()?.any { parseLine(it) == chapter } == true + } else { + false + } + }.toList() + if (matches.isEmpty()) return@forEach + var offset = 0 + matches.forEach { match -> + node.before(TextNode(text.substring(offset, match.range.first))) + node.before(Element("a").attr("href", "play-position:${seconds(match)}").text(match.value)) + offset = match.range.last + 1 + } + node.before(TextNode(text.substring(offset))) + node.remove() + } + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkClassifier.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkClassifier.kt new file mode 100644 index 000000000..21100c838 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkClassifier.kt @@ -0,0 +1,96 @@ +package cx.aswin.boxlore.core.catalog.shownotes + +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import java.util.Locale +import okhttp3.HttpUrl + +internal object EpisodeLinkClassifier { + private val profileRoutes = setOf("watch", "shorts", "playlist", "embed", "p", "reel", "reels", "explore", "search", "intent", "share", "sharer", "sharer.php", "login", "home", "settings", "privacy", "about", "accounts", "stories", "groups", "events", "pages", "profile.php", "hashtag", "i", "feed", "posts", "videos", "video", "clip") + private val socialDomains = mapOf( + "instagram.com" to "Instagram", "x.com" to "X", "twitter.com" to "X", + "threads.net" to "Threads", "threads.com" to "Threads", "facebook.com" to "Facebook", + "fb.com" to "Facebook", "linkedin.com" to "LinkedIn", "bsky.app" to "Bluesky", + ) + private val supportDomains = mapOf("patreon.com" to "Patreon", "ko-fi.com" to "Ko-fi", "buymeacoffee.com" to "Buy Me a Coffee") + private data class Source(val kind: EpisodeLinkKind, val platform: String? = null, val handle: String? = null) + + fun matchesHost(host: String, domain: String): Boolean = host == domain || host.endsWith(".$domain") + private fun matchesAny(host: String, vararg domains: String): Boolean = domains.any { matchesHost(host, it) } + + fun classify(url: HttpUrl, title: String?, context: String): EpisodeLink { + val host = url.host.removePrefix("www.") + val segments = url.pathSegments.filter(String::isNotBlank) + val source = sourceFor(host, segments) + return EpisodeLink(url.toString(), host, source.kind, EpisodeLinkTitles.destinationTitle(title, source.platform), source.platform, source.handle, context) + } + + private fun sourceFor(host: String, segments: List): Source { + val social = socialDomains.entries.firstOrNull { matchesHost(host, it.key) }?.value + val support = supportDomains.entries.firstOrNull { matchesHost(host, it.key) }?.value + return when { + matchesAny(host, "youtube.com", "youtu.be") -> youtube(segments) + matchesHost(host, "podcasts.apple.com") -> Source(EpisodeLinkKind.PODCAST, "Apple Podcasts") + matchesHost(host, "open.spotify.com") -> spotify(segments) + support != null -> Source(EpisodeLinkKind.SUPPORT, support, supportHandle(segments)) + matchesHost(host, "reddit.com") -> reddit(segments) + matchesAny(host, "discord.com", "discord.gg") -> Source(EpisodeLinkKind.COMMUNITY, "Discord") + matchesAny(host, "tiktok.com", "twitch.tv") -> streaming(host, segments) + social != null -> Source(EpisodeLinkKind.SOCIAL, social, socialHandle(segments, social)) + else -> website(segments) + } + } + + private fun youtube(segments: List): Source { + val first = segments.firstOrNull().orEmpty() + val handle = when { + first.startsWith("@") -> first + first in setOf("c", "user") -> segments.getOrNull(1)?.let { "@$it" } + else -> null + } + val kind = if (handle != null || first == "channel") EpisodeLinkKind.SOCIAL else EpisodeLinkKind.VIDEO + return Source(kind, "YouTube", handle) + } + + private fun spotify(segments: List): Source = Source( + if (segments.firstOrNull() in setOf("show", "episode")) EpisodeLinkKind.PODCAST else EpisodeLinkKind.WEBSITE, + "Spotify", + ) + + private fun supportHandle(segments: List): String? = segments.firstOrNull()?.takeUnless { it in setOf("home", "join", "posts") } + + private fun reddit(segments: List): Source { + val first = segments.firstOrNull() + val handle = if (first in setOf("r", "u", "user")) segments.getOrNull(1)?.let { "$first/$it" } else null + return Source(EpisodeLinkKind.COMMUNITY, "Reddit", handle) + } + + private fun streaming(host: String, segments: List): Source = Source( + if (segments.any { it in setOf("video", "videos", "clip") }) EpisodeLinkKind.VIDEO else EpisodeLinkKind.SOCIAL, + if (matchesHost(host, "tiktok.com")) "TikTok" else "Twitch", + socialHandle(segments, if (matchesHost(host, "tiktok.com")) "TikTok" else "Twitch"), + ) + + private fun socialHandle(segments: List, platform: String): String? { + val first = segments.firstOrNull().orEmpty() + val route = first.lowercase(Locale.ROOT) + return when { + platform == "Bluesky" -> segments.getOrNull(1)?.takeIf { route == "profile" } + platform == "LinkedIn" -> segments.getOrNull(1)?.takeIf { route in setOf("in", "company", "school") } + platform in setOf("Threads", "TikTok") -> first.takeIf { it.startsWith("@") } + first.isNotEmpty() && route !in profileRoutes -> first + else -> null + }?.takeIf { it.matches(Regex("@?[\\p{L}\\p{N}_.-]+")) }?.let { if (it.startsWith("@")) it else "@$it" } + } + + private fun website(segments: List): Source { + val routes = segments.map { it.lowercase(Locale.ROOT) } + return Source( + when { + routes.any { it in setOf("donate", "support", "funding", "membership") } -> EpisodeLinkKind.SUPPORT + routes.any { it in setOf("article", "articles", "story", "stories", "blog", "news", "posts") } -> EpisodeLinkKind.ARTICLE + else -> EpisodeLinkKind.WEBSITE + } + ) + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkTitles.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkTitles.kt new file mode 100644 index 000000000..76ec02417 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/EpisodeLinkTitles.kt @@ -0,0 +1,22 @@ +package cx.aswin.boxlore.core.catalog.shownotes + +import java.util.Locale + +/** A platform label is not the title of the profile, programme, or community it links to. */ +object EpisodeLinkTitles { + private val actionPrefix = Regex("""^(?:visit|follow|view|open|watch|listen|support|join|subscribe)(?:\s+us)?(?:\s+(?:on|in|to))?\s+""") + + fun destinationTitle(title: String?, platform: String?): String? { + val text = title?.trim()?.takeIf(String::isNotBlank) ?: return null + val platformName = platform?.lowercase(Locale.ROOT) ?: return text + val aliases = when (platformName) { + "x" -> setOf("x", "twitter", "x (twitter)", "twitter (x)") + "facebook" -> setOf("facebook", "fb") + "ko-fi" -> setOf("ko-fi", "ko fi", "kofi") + "buy me a coffee" -> setOf("buy me a coffee", "buymeacoffee") + else -> setOf(platformName) + } + val label = text.lowercase(Locale.ROOT).replace(Regex("\\s+"), " ").trimEnd('.', '!', ':') + return text.takeUnless { label in aliases || label.replaceFirst(actionPrefix, "") in aliases } + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParser.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParser.kt new file mode 100644 index 000000000..a70dc232e --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParser.kt @@ -0,0 +1,116 @@ +package cx.aswin.boxlore.core.catalog.shownotes + +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import cx.aswin.boxlore.core.model.ShowNotes +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + +/** Parses publisher HTML locally; never fetches linked pages or guesses a relative URL's origin. */ +object ShowNotesParser { + private val urlRegex = Regex("""(?i)\b(?:https?://|www\.)[^\s<>"']+""") + private val genericTitle = Regex("""(?i)^(?:click here|here|link|(?:our |the )?website|visit (?:our |the )?website|read more|learn more|listen now|listen here|subscribe here|https?://.*|www\..*)$""") + private val trackingNames = setOf("fbclid", "gclid", "mc_cid", "mc_eid") + private val infrastructure = setOf("podtrac.com", "chartable.com", "feedburner.com", "podcastindex.org") + private val mediaExtensions = Regex("""(?i)\.(?:mp3|m4a|mp4|wav|ogg|rss|xml)$""") + + fun parse(description: String?, baseUrl: String? = null, durationSeconds: Int = 0): ShowNotes { + val body = Jsoup.parseBodyFragment(description.orEmpty(), baseUrl?.toHttpUrlOrNull()?.toString().orEmpty()).body() + body.select("script,style,noscript").remove() + val plain = plainText(body) + val links = extractLinks(body) + val chapters = DescriptionChapters.parse(plain, durationSeconds) + DescriptionChapters.linkTimestamps(body, chapters) + body.ownerDocument()?.outputSettings()?.prettyPrint(false) + return ShowNotes(plain, links, chapters, body.html()) + } + + private fun plainText(body: Element): String { + val text = StringBuilder() + NodeTraversor.traverse( + object : NodeVisitor { + override fun head(node: Node, depth: Int) { + when { + node is TextNode -> text.append(node.wholeText) + node is Element && (node.isBlock || node.normalName() == "br") -> text.append('\n') + } + } + override fun tail(node: Node, depth: Int) { + if (node is Element && node.isBlock) text.append('\n') + } + }, + body + ) + return text.toString().replace('\u00a0', ' ').lines() + .map { it.replace(Regex("[\\t ]+"), " ").trim() } + .filter(String::isNotBlank).joinToString("\n") + } + + private fun extractLinks(body: Element): List { + val links = linkedMapOf() + body.select("a[href]").forEach { anchor -> + val href = anchor.attr("href").trim() + val absolute = if (href.startsWith("mailto:", true)) href else anchor.absUrl("href").ifBlank { href } + val title = anchor.text().trim().takeIf { it.isNotBlank() && !genericTitle.matches(it) } + val parsed = link(absolute, title, context(anchor)) + if (parsed != null) { + anchor.attr("href", parsed.url) + links.putIfAbsent(canonicalKey(parsed), parsed) + } else if (!href.startsWith("play-position:")) { + anchor.removeAttr("href") + } + } + NodeTraversor.traverse( + object : NodeVisitor { + override fun head(node: Node, depth: Int) { + if (node !is TextNode || node.noteAncestors().any { it.normalName() in setOf("a", "code", "pre") }) return + urlRegex.findAll(node.wholeText).forEach { match -> + link(trimPunctuation(match.value), null, context(node))?.let { links.putIfAbsent(canonicalKey(it), it) } + } + } + override fun tail(node: Node, depth: Int) = Unit + }, + body + ) + return links.values.toList() + } + + private fun context(node: Node): String = node.noteAncestors().firstOrNull { it.normalName() in setOf("p", "li", "div") } + ?.text()?.take(500).orEmpty() + + private fun link(raw: String, title: String?, context: String): EpisodeLink? { + if (raw.startsWith("mailto:", true)) { + val address = raw.substringAfter(':').substringBefore('?') + if (!address.matches(Regex("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))) return null + return EpisodeLink(raw, address, EpisodeLinkKind.EMAIL, title, context = context) + } + val url = (if (raw.startsWith("www.", true)) "https://$raw" else raw).toHttpUrlOrNull() ?: return null + if (url.username.isNotEmpty() || url.password.isNotEmpty()) return null + if (infrastructure.any { EpisodeLinkClassifier.matchesHost(url.host, it) } || mediaExtensions.containsMatchIn(url.encodedPath)) return null + return EpisodeLinkClassifier.classify(url, title, context) + } + + private fun canonicalKey(link: EpisodeLink): String { + val url = link.url.toHttpUrlOrNull() ?: return link.url + val canonical = url.newBuilder().query(null) + url.queryParameterNames.filterNot { it.startsWith("utm_", true) || it.lowercase(java.util.Locale.ROOT) in trackingNames } + .sorted().forEach { name -> + url.queryParameterValues(name).sortedWith(compareBy { it.orEmpty() }).forEach { canonical.addQueryParameter(name, it) } + } + // Paths, query values, and meaningful fragments identify distinct resources. + return canonical.build().toString() + } + + private fun trimPunctuation(raw: String): String { + var result = raw.trimEnd('.', ',', ';', '!', ':', ']') + while (result.endsWith(')') && result.count { it == ')' } > result.count { it == '(' }) result = result.dropLast(1) + return result + } +} + +internal fun Node.noteAncestors(): Sequence = generateSequence(parentNode()) { it.parentNode() }.filterIsInstance() diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetectorTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetectorTest.kt index e86604c2f..8bc9a47e7 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetectorTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionDetectorTest.kt @@ -5,249 +5,109 @@ import cx.aswin.boxlore.core.model.CrossPromotionIndicator import cx.aswin.boxlore.core.model.Episode import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class CrossPromotionDetectorTest { private val detector = CrossPromotionDetector() - - private fun createEpisode( - title: String, - duration: Int = 0, - episodeType: String? = null, - episodeNumber: Int? = null, - description: String = "Test description", - ): Episode = Episode( - id = "test-id", - title = title, - description = description, - audioUrl = "http://example.com/audio.mp3", - duration = duration, - episodeType = episodeType, - episodeNumber = episodeNumber, - ) + private fun episode(title: String, duration: Int = 1800, type: String? = "full", description: String = "Discussion notes.") = + Episode(id = "ep", title = title, audioUrl = "https://example.org/audio.mp3", duration = duration, episodeType = type, description = description) @Test - fun testStrictDelimiterPattern() { - val cases = - listOf( - "Feed Drop: Serial" to "Serial", - "Trailer Swap - Crime Junkie" to "Crime Junkie", - "promo drop || planet money" to "planet money", - "Bonus Drop: Radiolab" to "Radiolab", - "Listen Now: Lawless Planet" to "Lawless Planet", - "Sneak Peek: Normal Show" to "Normal Show", // Delimiter matches now run regardless of duration - "Feed Swap: Serial" to "Serial", - "Feed Share: Crime Junkie" to "Crime Junkie", - "Promo Swap: Planet Money" to "Planet Money", - "Special Preview: Radiolab" to "Radiolab", - "Listen To: Lawless Planet" to "Lawless Planet", - ) - - for ((title, expectedShow) in cases) { - val episode = createEpisode(title = title, duration = 300) // long duration - val result = detector.detect(episode, "Host Podcast") - println( - "StrictDelimiter - TITLE: '$title', RESULT isCrossPromo: ${result.isCrossPromotion}, extractedShowName: '${result.extractedShowName}'", - ) - assertTrue(result.isCrossPromotion, "Should detect cross promo for: $title") + fun `explicit feed exchanges work independently of duration or numbering`() { + listOf("Feed Drop: Serial", "Trailer Swap - Serial", "Promo Drop || Serial", "Bonus Drop: Serial", "Feed Share: Serial", "Guest Feed: Serial", "Companion Show: Serial", "Network Premiere: Serial").forEach { title -> + val result = detector.detect(episode(title), "Host") + assertTrue(result.isCrossPromotion, title) + assertEquals("Serial", result.extractedShowName) assertEquals(CrossPromotionConfidence.HIGH, result.confidence) - assertEquals(expectedShow, result.extractedShowName) - assertTrue(result.matchedIndicators.contains(CrossPromotionIndicator.TITLE_DELIMITER_PATTERN)) } } @Test - fun testStrictPresentsPattern() { - val cases = - listOf( - "NPR Presents: Planet Money" to "Planet Money", - "Presented by NPR: Planet Money" to "Planet Money", - "From the creators of Suspect: Origin Stories" to "Origin Stories", - "Brought to you by Wondery: Dr. Death" to "Dr. Death", - ) - - for ((title, expectedShow) in cases) { - val episode = createEpisode(title = title, duration = 400) - val result = detector.detect(episode, "Host Podcast") - println( - "StrictPresents - TITLE: '$title', RESULT isCrossPromo: ${result.isCrossPromotion}, extractedShowName: '${result.extractedShowName}'", - ) - assertTrue(result.isCrossPromotion) - assertEquals(expectedShow, result.extractedShowName) + fun `introductions and previews require supporting evidence`() { + listOf("Introducing: The Daily", "Listen Now: The Daily", "Special Preview: The Daily", "Introducing The Daily", "NPR Presents: The Daily", "Presented by NPR: The Daily", "From the creators of Serial: The Daily").forEach { title -> + assertFalse(detector.detect(episode(title), "Host").isCrossPromotion, title) + val result = detector.detect(episode(title, type = "trailer", description = "Subscribe to The Daily wherever you get podcasts."), "Host") + assertTrue(result.isCrossPromotion, title) + assertEquals("The Daily", result.extractedShowName) assertEquals(CrossPromotionConfidence.HIGH, result.confidence) - assertTrue(result.matchedIndicators.contains(CrossPromotionIndicator.TITLE_PRESENTS_PATTERN)) } } @Test - fun testConditionalStrictPattern() { - val cases = - listOf( - "Introducing: The Daily" to "The Daily", - "Discover: The Daily" to "The Daily", - "Meet: The Daily" to "The Daily", - "Check Out: The Daily" to "The Daily", - "We Recommend: The Daily" to "The Daily", - "Announcing: The Daily" to "The Daily", - "New Season: The Daily" to "The Daily", - "Brand New Season: The Daily" to "The Daily", - "Next Season: The Daily" to "The Daily", - "New Sesson: The Daily" to "The Daily", - "Next Sesson: The Daily" to "The Daily", - "Sesson: The Daily" to "The Daily", - "Next Seaton: The Daily" to "The Daily", - ) - - for ((title, expectedShow) in cases) { - val episodeLong = createEpisode(title = title, duration = 600) - val resultLong = detector.detect(episodeLong, "Host Podcast") - println( - "ConditionalStrict - TITLE: '$title' (Long) - isCrossPromo: ${resultLong.isCrossPromotion}, extractedShowName: '${resultLong.extractedShowName}'", - ) - assertTrue(resultLong.isCrossPromotion) - assertEquals(expectedShow, resultLong.extractedShowName) - - val episodeShort = createEpisode(title = title, duration = 90) - val resultShort = detector.detect(episodeShort, "Host Podcast") - println( - "ConditionalStrict - TITLE: '$title' (Short) - isCrossPromo: ${resultShort.isCrossPromotion}, extractedShowName: '${resultShort.extractedShowName}'", - ) - assertTrue(resultShort.isCrossPromotion) - assertEquals(CrossPromotionConfidence.HIGH, resultShort.confidence) - assertEquals(expectedShow, resultShort.extractedShowName) - assertTrue(resultShort.matchedIndicators.contains(CrossPromotionIndicator.TITLE_DELIMITER_PATTERN)) + fun `short introduction is possible promotion while weak topic titles still need corroboration`() { + val introduction = detector.detect(episode("Introducing The Daily Show", duration = 90), "Host") + assertTrue(introduction.isCrossPromotion) + assertEquals(CrossPromotionConfidence.MEDIUM, introduction.confidence) + assertTrue(CrossPromotionIndicator.SHORT_DURATION in introduction.matchedIndicators) + listOf("Discover: The Universe", "Meet: Our Guest", "New Season: Season 4", "Introducing Season 3").forEach { + assertFalse(detector.detect(episode(it, duration = 90, type = "trailer"), "Host").isCrossPromotion, it) } + val confirmed = detector.detect(episode("Discover: The Daily", description = "Listen to “The Daily” wherever you get podcasts."), "Host") + assertTrue(confirmed.isCrossPromotion) } @Test - fun testOptionalIndicators_SeamlessIntroducingWithShortDuration() { - // "Introducing" seamless (no colon) + duration = 90s (2 optional indicators) - val episode = - createEpisode( - title = "Introducing The Daily Show", - duration = 90, - episodeType = "full", - episodeNumber = 12, - ) - val result = detector.detect(episode, "Host Podcast") - assertTrue(result.isCrossPromotion) - assertEquals(CrossPromotionConfidence.MEDIUM, result.confidence) - assertEquals("The Daily Show", result.extractedShowName) - assertTrue(result.matchedIndicators.contains(CrossPromotionIndicator.TITLE_SEAMLESS_INTRODUCING)) - assertTrue(result.matchedIndicators.contains(CrossPromotionIndicator.SHORT_DURATION)) - } - - @Test - fun testOptionalIndicators_TrailerTypeAndShortDurationNoName() { - // duration = 90s + type = "trailer" + missing number = 3 optional indicators - // BUT no seamless introducing regex matches, so no name is extracted. - // Should NOT be cross-promotional because we don't have a name. - val episode = - createEpisode( - title = "My Own Season Trailer", - duration = 90, - episodeType = "trailer", - episodeNumber = null, - ) - val result = detector.detect(episode, "Host Podcast") - assertFalse(result.isCrossPromotion) - assertNull(result.extractedShowName) - } - - @Test - fun testSamePodcastGuard() { - // Matches delimiter but same show name -> ignore - val episode1 = createEpisode(title = "Feed Drop: Serial", duration = 300) - val result1 = detector.detect(episode1, "Serial") - assertFalse(result1.isCrossPromotion) - - // Matches delimiter but host title contains extracted name -> ignore - val episode2 = createEpisode(title = "Introducing: The Daily", duration = 90) - val result2 = detector.detect(episode2, "The Daily Podcast") - assertFalse(result2.isCrossPromotion) - - // Matches optional seamless but host title contains extracted name -> ignore - val episode3 = createEpisode(title = "Introducing Serial", duration = 90) - val result3 = detector.detect(episode3, "Serial") - assertFalse(result3.isCrossPromotion) + fun `bonus unnumbered episodes and evergreen footer do not cause promotion`() { + listOf("Episode 47: Introducing the New iPhone", "A conversation with Serial", "Bonus interview", "Morning headlines").forEach { + assertFalse(detector.detect(episode(it, type = "bonus", description = "Listen to “Another Show” wherever you get podcasts."), "Host").isCrossPromotion, it) + } + assertFalse(detector.detect(episode("Short update", duration = 90, description = "Follow us on Instagram for more news."), "Host").isCrossPromotion) } @Test - fun testNegativeLookaheadForSeason() { - // "Introducing Season 3" matches seamless but negative lookahead should block it - val episode = createEpisode(title = "Introducing Season 3", duration = 90) - val result = detector.detect(episode, "Host Podcast") - assertFalse(result.isCrossPromotion) + fun `short regular episode with another shows evergreen footer is not a preview`() { + assertFalse(detector.detect(episode("Morning headlines", duration = 90, description = "Subscribe to Another Show wherever you get your podcasts."), "Host").isCrossPromotion) + assertFalse(detector.detect(episode("Trailer", type = "trailer", description = "

    Listen on Apple Podcasts

    "), "Host").isCrossPromotion) } @Test - fun testNormalEpisode() { - val episode = - createEpisode( - title = "Episode 47: Introducing the New iPhone", - duration = 3000, - episodeType = "full", - episodeNumber = 47, - ) - val result = detector.detect(episode, "Host Podcast") - assertFalse(result.isCrossPromotion) + fun `description supplies a name only with corroboration and retains the article The`() { + val result = detector.detect(episode("A special preview", duration = 95, type = "trailer", description = "Subscribe to The Daily wherever you get your podcasts."), "Host") + assertTrue(result.isCrossPromotion) + assertEquals("The Daily", result.extractedShowName) + val linked = detector.detect(episode("A special preview", type = "trailer", description = "

    Listen to Échos du monde

    "), "Host") + assertTrue(linked.isCrossPromotion) + assertEquals("Échos du monde", linked.extractedShowName) } @Test - fun testGuestFeedAndCompanionPatterns() { - val guest = - detector.detect( - createEpisode(title = "Guest Feed: Serial", duration = 120), - "Host Podcast", - ) - assertTrue(guest.isCrossPromotion) - assertEquals("Serial", guest.extractedShowName) - - val companion = - detector.detect( - createEpisode(title = "Companion Show: Crime Junkie", duration = 90), - "Host Podcast", - ) - assertTrue(companion.isCrossPromotion) - assertEquals("Crime Junkie", companion.extractedShowName) + fun `self promotion uses exact normalized names rather than substring exclusion`() { + assertFalse(detector.detect(episode("Feed Drop: THE DAILY"), "The Daily Podcast").isCrossPromotion) + assertFalse(detector.detect(episode("Season trailer", type = "trailer", duration = 90, description = "Subscribe to Host Podcast for more episodes."), "Host").isCrossPromotion) + assertTrue(detector.detect(episode("Feed Drop: Serial"), "Serial Killers").isCrossPromotion) } @Test - fun testDescriptionSubscribeLanguage() { - val episode = - createEpisode( - title = "A Special Preview", - duration = 95, - episodeType = "trailer", - episodeNumber = null, - description = "Subscribe to \"Lawless Planet\" wherever you get your podcasts.", - ) - val result = detector.detect(episode, "Host Podcast") - assertTrue(result.isCrossPromotion) - assertEquals("Lawless Planet", result.extractedShowName) - assertTrue(result.matchedIndicators.contains(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE)) + fun `nine minute full introduction with a weekday schedule is detected`() { + val title = "Introducing: CONSPIRACY THEORIES, CULTS, AND CRIMES" + val description = "Listen to Conspiracy Theories, Cults, and Crimes every Wednesday as we explore the real people at the center of the world's most shocking secrets and nefarious organizations." + listOf(description, "

    $description

    ").forEach { notes -> + val result = detector.detect(episode(title, duration = 558, description = notes), "Infamous America") + assertTrue(result.isCrossPromotion) + assertEquals("CONSPIRACY THEORIES, CULTS, AND CRIMES", result.extractedShowName) + assertEquals(CrossPromotionConfidence.HIGH, result.confidence) + assertTrue(CrossPromotionIndicator.DESCRIPTION_PROMO_LANGUAGE in result.matchedIndicators) + } } @Test - fun testDescriptionDoesNotPromoteHostShow() { - val episode = - createEpisode( - title = "Season Trailer", - duration = 90, - episodeType = "trailer", - episodeNumber = null, - description = "Subscribe to Host Podcast for more episodes every week.", - ) - val result = detector.detect(episode, "Host Podcast") - assertFalse(result.isCrossPromotion) + fun `known promoted names tolerate long sentences punctuation and schedules`() { + listOf("every Thursday", "weekly", "as we meet people " + "with unexpected stories ".repeat(10), ". New episodes arrive soon.").forEach { continuation -> + val result = detector.detect(episode("Introducing: Science with Alice", description = "Listen to our new podcast Science with Alice $continuation", duration = 1200), "Host") + assertTrue(result.isCrossPromotion, continuation) + assertEquals("Science with Alice", result.extractedShowName) + } + assertTrue(detector.detect(episode("Introducing: Cults and Crimes", description = "Listen to Cults & Crimes every Friday."), "Host").isCrossPromotion) + assertTrue(detector.detect(episode("Introducing: The Podcast Industry", description = "Listen to The Podcast Industry as we meet people " + "with unexpected stories ".repeat(10)), "Host").isCrossPromotion) + assertFalse(detector.detect(episode("Introducing: Cults & Crimes", description = "Listen to Cults and Crimes every Friday."), "Cults and Crimes").isCrossPromotion) } @Test - fun testSeasonOnlyNameRejected() { - val episode = createEpisode(title = "New Season: Season 4", duration = 120) - val result = detector.detect(episode, "Host Podcast") - assertFalse(result.isCrossPromotion) + fun `description cannot corroborate a shorter show prefix or an unrelated mention`() { + listOf("Listen to Serial Killers every Wednesday.", "We discuss Serial on this episode.", "Follow us on Instagram for more Serial updates.").forEach { description -> + assertFalse(detector.detect(episode("Introducing: Serial", description = description), "Host").isCrossPromotion, description) + } + assertFalse(detector.detect(episode("Morning headlines", duration = 90, description = "Listen to Another Show every Wednesday."), "Host").isCrossPromotion) } } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolverTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolverTest.kt new file mode 100644 index 000000000..591d9cdb3 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/crosspromo/CrossPromotionResolverTest.kt @@ -0,0 +1,122 @@ +package cx.aswin.boxlore.core.catalog.crosspromo + +import cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.testing.TestFixtures +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CrossPromotionResolverTest { + private fun show(id: String, title: String): Podcast = TestFixtures.podcast(id = id, title = title) + + @Test + fun `search preserves full name including colon and with and checks every result`() = runTest { + val name = "Science Weekly: Conversations with Alice" + val expected = show("target", name) + val resolver = CrossPromotionResolver(search = { query -> + assertEquals(name, query) + (1..12).map { show("other$it", "Unrelated $it") } + expected + }, lookup = { null }) + assertEquals(expected, resolver.resolve(name)) + } + + @Test + fun `host ambiguous matches and generic overlap never resolve`() = runTest { + val resolver = CrossPromotionResolver({ emptyList() }, { null }) + assertNull(resolver.bestMatch(listOf(show("host", "Serial")), "Serial", "host")) + assertNull(resolver.bestMatch(listOf(show("1", "Science Weekly"), show("2", "Science Weekly")), "Science Weekly", null)) + assertNull(resolver.bestMatch(listOf(show("1", "The Daily News")), "The Daily", null)) + assertEquals("2", resolver.bestMatch(listOf(show("1", "Science Weekly: Bonus"), show("2", "Science Weekly")), "Science Weekly", null)?.id) + } + + @Test + fun `explicit Apple show link resolves stable identity without guessing`() = runTest { + val target = show("42", "Target Show") + val links = ShowNotesParser.parse("

    Listen to Target Show

    ").links + val resolver = CrossPromotionResolver(search = { error("Search should not run") }, lookup = { id -> + assertEquals("itunes:9876", id) + target + }) + assertEquals(target, resolver.resolve("Target Show", "host", links)) + } + + @Test + fun `multiple unnamed podcast links cannot select an arbitrary destination`() = runTest { + val links = ShowNotesParser.parse("

    Listen to Target Show on Apple Podcasts or Apple Podcasts.

    ").links + val resolver = CrossPromotionResolver({ listOf(show("target", "Target Show")) }, { error("Ambiguous link must not be looked up") }) + assertEquals("target", resolver.resolve("Target Show", links = links)?.id) + } + + @Test + fun `network failure is retryable and cancellation propagates`() = runTest { + var calls = 0 + val resolver = CrossPromotionResolver({ + calls++ + if (calls == 1) throw java.io.IOException("offline") else listOf(show("target", "Target Show")) + }, { null }) + assertNull(resolver.resolve("Target Show")) + assertEquals("target", resolver.resolve("Target Show")?.id) + assertEquals(2, calls) + val cancelled = CrossPromotionResolver({ throw CancellationException("cancel") }, { null }) + try { + cancelled.resolve("Target Show") + throw AssertionError("Cancellation must propagate") + } catch (_: CancellationException) { + // Expected: callers retain structured cancellation. + } + } + + @Test + fun `failed Apple lookup falls back to an exact search match`() = runTest { + val links = ShowNotesParser.parse("Target Show").links + val resolver = CrossPromotionResolver({ listOf(show("target", "Target Show")) }, { throw java.io.IOException("lookup offline") }) + assertEquals("target", resolver.resolve("Target Show", links = links)?.id) + } + + @Test + fun `empty results expire quickly and host identity scopes the cache`() = runTest { + var time = 0L + var calls = 0 + val resolver = CrossPromotionResolver({ + calls++ + if (calls == 1) emptyList() else listOf(show("target", "Target Show")) + }, { null }, { time }) + assertNull(resolver.resolve("Target Show")) + assertNull(resolver.resolve("Target Show")) + assertEquals(1, calls) + time = 60_001L + assertEquals("target", resolver.resolve("Target Show")?.id) + assertNull(resolver.resolve("Target Show", hostPodcastId = "target")) + assertEquals(3, calls) + } + + @Test + fun `standalone listen now link resolves a verified title without search`() = runTest { + val target = show("itunes:1828469754", "Conspiracy Theories, Cults, & Crimes") + val name = "CONSPIRACY THEORIES, CULTS, AND CRIMES" + listOf( + "Listen Now: https://podcasts.apple.com/us/podcast/conspiracy-theories-cults-crimes/id1828469754", + "

    Listen Now

    ", + ).forEach { description -> + val resolver = CrossPromotionResolver({ error("Matching direct link should not search") }, { id -> + assertEquals("itunes:1828469754", id) + target + }) + assertEquals(target, resolver.resolve(name, "host", ShowNotesParser.parse(description).links)) + } + } + + @Test + fun `lone generic link must match the name and cannot resolve the host`() = runTest { + val links = ShowNotesParser.parse("https://podcasts.apple.com/us/podcast/id123").links + listOf(show("other", "Unrelated Show"), show("host", "Target Show")).forEach { linked -> + val resolver = CrossPromotionResolver({ emptyList() }, { linked }) + assertNull(resolver.resolve("Target Show", "host", links)) + } + val resolver = CrossPromotionResolver({ listOf(show("correct", "Target Show")) }, { show("wrong", "Unrelated Show") }) + assertEquals("correct", resolver.resolve("Target Show", "host", links)?.id) + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParserTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParserTest.kt new file mode 100644 index 000000000..f34247fa0 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/shownotes/ShowNotesParserTest.kt @@ -0,0 +1,144 @@ +package cx.aswin.boxlore.core.catalog.shownotes + +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ShowNotesParserTest { + @Test + fun `platform anchor labels are not profile titles and real handles survive`() { + val profiles = listOf( + Triple("https://instagram.com/radiolab", "Instagram", "@radiolab"), + Triple("https://facebook.com/radiolab", "Visit Facebook", "@radiolab"), + Triple("https://threads.net/@radiolab", "Threads", "@radiolab"), + Triple("https://twitter.com/radiolab", "Twitter", "@radiolab"), + Triple("https://x.com/radiolab", "Follow us on X", "@radiolab"), + Triple("https://bsky.app/profile/radiolab.org", "Bluesky", "@radiolab.org"), + Triple("https://linkedin.com/company/radiolab", "LinkedIn", "@radiolab"), + Triple("https://tiktok.com/@radiolab", "TikTok", "@radiolab"), + Triple("https://instagram.com/instagram", "Instagram", "@instagram"), + ) + profiles.forEach { (url, label, handle) -> + val link = ShowNotesParser.parse("$label").links.single() + assertEquals(null, link.title, url) + assertEquals(handle, link.handle, url) + } + } + + @Test + fun `social root login and post routes do not become usernames`() { + listOf("https://instagram.com/", "https://instagram.com/accounts/login", "https://instagram.com/P/123", "https://facebook.com/profile.php?id=123", "https://facebook.com/share/123", "https://facebook.com/groups/123", "https://threads.com/intent/post", "https://x.com/i/flow/login", "https://linkedin.com/feed", "https://bsky.app/search", "https://tiktok.com/explore").forEach { url -> + assertEquals(null, ShowNotesParser.parse(url).links.single().handle, url) + } + } + + @Test + fun `meaningful publisher titles and names resembling platforms are preserved`() { + listOf( + "https://instagram.com/radiolab" to "Radiolab behind the scenes", + "https://threads.com/@history" to "Threads of history", + "https://instagram.com.evil.org/radiolab" to "Instagram", + "https://example.org/news/youtube" to "YouTube", + ).forEach { (url, label) -> + assertEquals(label, ShowNotesParser.parse("$label").links.single().title) + } + } + + @Test + fun `HTML anchors preserve labels entities and known relative origin`() { + val notes = ShowNotesParser.parse("

    Our research & findings

    ", "https://radiolab.org/episodes/intro") + assertEquals("Our research & findings", notes.links.single().title) + assertEquals("https://radiolab.org/news/story?x=1&y=2", notes.links.single().url) + assertEquals(EpisodeLinkKind.ARTICLE, notes.links.single().kind) + assertTrue(ShowNotesParser.parse("About us").links.isEmpty()) + } + + @Test + fun `raw URL punctuation and generic anchors retain meaningful destinations`() { + val notes = ShowNotesParser.parse("Visit our website

    Research (https://example.org/paper_(2026)). And www.example.org/about!

    ") + assertEquals(listOf("radiolab.org", "example.org", "www.example.org".removePrefix("www.")), notes.links.map { it.host }) + assertEquals(null, notes.links.first().title) + assertEquals("https://example.org/paper_(2026)", notes.links[1].url) + assertEquals("https://www.example.org/about", notes.links[2].url) + } + + @Test + fun `tracking duplicates collapse while case and fragment distinctions survive`() { + val notes = ShowNotesParser.parse( + """ + Research + https://example.org/Paper?b=1&a=2 + https://example.org/paper?a=2&b=1 + https://example.org/Paper?a=2&b=1#methods + https://example.org/Paper?a=2&b=1#results + """.trimIndent() + ) + assertEquals(4, notes.links.size) + assertEquals("Research", notes.links.first().title) + assertTrue(notes.links.first().url.contains("utm_source")) + } + + @Test + fun `platform classification uses domain boundaries and path purpose`() { + val urls = listOf("https://youtube.com/watch?v=1", "https://youtube.com/@radiolab", "https://youtube.com.evil.org/watch?v=1", "https://notyoutube.com/watch", "https://open.spotify.com/show/123", "https://open.spotify.com/track/123", "https://reddit.com/r/podcasts", "https://patreon.com/radiolab", "mailto:hello@radiolab.org") + val links = ShowNotesParser.parse(urls.joinToString("\n") { "$it" }).links + assertEquals(listOf(EpisodeLinkKind.VIDEO, EpisodeLinkKind.SOCIAL, EpisodeLinkKind.WEBSITE, EpisodeLinkKind.WEBSITE, EpisodeLinkKind.PODCAST, EpisodeLinkKind.WEBSITE, EpisodeLinkKind.COMMUNITY, EpisodeLinkKind.SUPPORT, EpisodeLinkKind.EMAIL), links.map { it.kind }) + assertEquals("@radiolab", links[1].handle) + assertEquals("r/podcasts", links[6].handle) + assertEquals(null, links[2].platform) + } + + @Test + fun `unsafe and infrastructure links do not become resources`() { + val notes = ShowNotesParser.parse( + """ + + UnsafePrivate + AudioFeed + Real site + """.trimIndent() + ) + assertEquals("podtrac.com.evil.org", notes.links.single().host) + assertFalse(notes.html.contains("javascript:")) + assertFalse(notes.plainText.contains("bad.org")) + } + + @Test + fun `chapters support title first long minutes and ignore prose invalid times and duplicates`() { + val notes = ShowNotesParser.parse( + """ +

    We met at 12:30 yesterday.

    +

    00:00 — Introduction

    00:00 — Duplicate

    +

    The experiment — 12:30

    90:05 Long episode

    +

    01:60:00 Invalid

    99:99 Invalid

    02:00:00 After duration

    + """.trimIndent(), + durationSeconds = 6_000 + ) + assertEquals(listOf(0.0, 750.0, 5405.0), notes.chapters.map { it.startTime }) + assertEquals(listOf("Introduction", "The experiment", "Long episode"), notes.chapters.map { it.title }) + assertTrue(notes.html.contains("We met at 12:30 yesterday.")) + assertEquals(3, Regex("play-position:").findAll(notes.html).count()) + } + + @Test + fun `timestamps never rewrite attributes existing links or inline prose`() { + val notes = ShowNotesParser.parse( + """ +

    00:00 Introduction

    00:30 Interview

    +

    We recorded at 00:30 today.

    00:30 External + 00:30 Example + """.trimIndent() + ) + assertEquals(2, Regex("play-position:").findAll(notes.html).count()) + assertTrue(notes.html.contains("https://example.org/00:30")) + assertTrue(notes.html.contains("We recorded at 00:30 today.")) + assertTrue(notes.html.contains("00:30 Example")) + } + + @Test + fun `single clock time is not a chapter list`() { + assertTrue(ShowNotesParser.parse("

    12:30 Lunch break

    ").chapters.isEmpty()) + } +} diff --git a/core/model/README.md b/core/model/README.md index 1f7fe12c1..aad782798 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -18,6 +18,7 @@ Owns shared domain models, enums, and pure value helpers used across network, da - `AutoTranscriptState`. - `PodcastGenres` and `RankingAggregateTelemetry`. - Cross-promotion model types. +- `ShowNotes`, `EpisodeLink`, and `EpisodeLinkKind` carry locally parsed plain text, publisher HTML, chapters, resource purpose, destination, and publisher labels without UI or parser-library dependencies. - `Podcast.isLatestEpisodeNew`: shared NEW badge. Room `rssHasNewEpisodes` is true for true-RSS freshness **and** for Podcast Index direct-feed tip promotions (`updateLatestEpisode(..., markAsNew = true)`). Opening the show clears the flag. Otherwise the 48h window / last-seen id rules apply. - `Podcast.effectiveGenre`: resolves user `customGenre` override when non-blank, falling back to default catalog `genre`. Companion `customGenreIcon` stores the icon identifier for custom tags. - `Podcast.recommendationGenre`: resolves canonicalized `customGenre` via `PodcastGenres.canonicalize` so valid standard reclassifications adapt personalized recommendations and Smart Queue, falling back to `genre` when the tag is arbitrary. @@ -38,6 +39,7 @@ src/main/java/cx/aswin/boxlore/core/model/ PodcastGenres.kt RankingAggregateTelemetry.kt ShareLinkBuilder.kt + ShowNotes.kt SleepTimerConstants.kt Transcript.kt ContentRegion.kt diff --git a/core/model/src/main/java/cx/aswin/boxlore/core/model/ShowNotes.kt b/core/model/src/main/java/cx/aswin/boxlore/core/model/ShowNotes.kt new file mode 100644 index 000000000..0c31f9240 --- /dev/null +++ b/core/model/src/main/java/cx/aswin/boxlore/core/model/ShowNotes.kt @@ -0,0 +1,21 @@ +package cx.aswin.boxlore.core.model + +/** Destination purpose, independent of UI icons, colours and translated action labels. */ +enum class EpisodeLinkKind { WEBSITE, ARTICLE, VIDEO, SOCIAL, SUPPORT, PODCAST, COMMUNITY, EMAIL } + +data class EpisodeLink( + val url: String, + val host: String, + val kind: EpisodeLinkKind, + val title: String? = null, + val platform: String? = null, + val handle: String? = null, + val context: String = "", +) + +data class ShowNotes( + val plainText: String, + val links: List, + val chapters: List, + val html: String, +) diff --git a/core/playback/README.md b/core/playback/README.md index 230607373..c92079df2 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -97,6 +97,8 @@ Files under `core/data/service` are compatibility stubs for old service class na `CoilBitmapLoader` dispatches both byte-array decoding and URI artwork loading to IO in the service scope. Media3 callers receive a cancellable future immediately; decode errors complete that future exceptionally. Notification covers request at most 512×512 pixels and software bitmaps. `CoilBitmapLoaderTest` covers asynchronous decoding, invalid data and service cancellation. +- Release runtime dependency snapshots include Jsoup through `:core:catalog`, which owns shared show-notes resource and chapter parsing. No direct parser-library dependency is added here. + ## Threading / lifecycle - `PlaybackRepository`, `QueueRepository`, and `QueueManager` are application-scoped through `AppContainer`. diff --git a/core/playback/dependencies/releaseRuntimeClasspath.txt b/core/playback/dependencies/releaseRuntimeClasspath.txt index 0c5ca9455..fdb322961 100644 --- a/core/playback/dependencies/releaseRuntimeClasspath.txt +++ b/core/playback/dependencies/releaseRuntimeClasspath.txt @@ -212,4 +212,5 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3 org.jetbrains:annotations:23.0.0 +org.jsoup:jsoup:1.23.2 org.jspecify:jspecify:1.0.0 diff --git a/feature/info/README.md b/feature/info/README.md index 5b1e51597..a99f2a1fc 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -11,7 +11,7 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - RSS-only shows expose the same notification and auto-download toolbar controls as catalog shows. Both the bell and the auto-download notice’s **Turn on notifications** action open `RssNotificationDisclosureDialog` before Android permission handling: users confirm that turning notifications on will make the feed link public. The dialog advises keeping notifications off for private or premium feeds and explains that listening, refresh and downloads remain available. Cancelling leaves notifications off; auto-download works independently. Existing imported shows use the same disclosure. - `PodcastInfoScreen` and `PodcastInfoViewModel`. Long-pressing an episode enters multi-selection; the floating toolbar can download, mark completed (or mark unplayed when every selected episode is already complete), play, or append selected episodes to the queue. Its overflow can select only cards currently visible on screen, or fetch up to 100 show episodes for Select all / Select older / Select newer; fetched episode metadata is retained only while selection is active. Episode sorting remains available. Subscribed shows expose **Pin to Home screen** / **Unpin from Home screen** and **Change tag / genre** in the overflow menu (or by tapping the genre chip directly) to open `PodcastGenreEditSheet` with IME keyboard support, real-time genre search pairing names with their icons, cross-suggestions from existing subscription folder names, and a 31-icon Material Rounded palette. Unsubscribing clears custom genre tags and icons back to default. Tapping the download action on an already-downloaded episode prompts with a Material 3 confirmation dialog (`RemoveDownloadConfirmationDialog`) before removing the download. Episode play taps and multi-selection play pass `podcast_detail` as the playback entry point context (allowing same-show continuation in Smart Queue) while preserving any spotlight route for video telemetry. Show notification toggles feature system permission awareness: when show notifications are active but device notifications are disabled, `EpisodeToolbar` renders the active bell icon in an error-container red accent with a red badge dot; tapping the icon reveals `ToolbarWarning.SYSTEM_PERMISSION_BLOCKED` explaining the status; the "Turn On" action prompts for `POST_NOTIFICATIONS` runtime permission when available, and otherwise opens system settings. -- `EpisodeInfoScreen` and `EpisodeInfoViewModel` (similar episodes use prefs `content_languages` + region). Tapping download on a downloaded episode prompts for confirmation before removing local media. +- `EpisodeInfoScreen` and `EpisodeInfoViewModel` (similar episodes use prefs `content_languages` + region). The artwork-first Material 3 layout keeps a visible blurred cover backdrop within shorter clipped bounds, uses theme-based tonal surfaces and plain Back/Share icons with 48dp touch targets, and limits episode titles to three lines with an overflow-only arrow inside a small translucent circle at the trailing end of the third line. The first two title lines retain the full width; expanded titles keep all text visible and place the collapse arrow at the end. The generic Episode toolbar label is omitted; the episode title still appears when scrolled. The hero podcast name uses one line with ellipsis; Read more / Show less use visible filled-tonal pills. Like, Download and Queue use connected tonal toggle buttons with rounded outer ends, small gaps, and rounded selected states; a wider filled Play button completes the same 56dp-high row; listening progress and its time summary sit below the action group; Bonus/Trailer/Video tags sit inside the artwork at the top-start corner as compact opaque tonal pills with a 12dp inset; paired tags wrap for larger text or longer translations. Date, duration and season/episode numbers use middle-dot separators below the title in a single-line row. The completion pill stays fixed at the logical end; metadata takes the remaining width and marquees only when it overflows, with a 2.5-second initial pause, 2-second pauses between passes, and a 24dp/s scroll speed. Short metadata stays still, missing values do not leave stray separators, and the complete metadata remains available to accessibility. The pill label stays on one line, with ellipsis only when an unusually long translation or enlarged text exceeds the available width. The pill shows Mark played before completion and Played after completion; tapping it toggles the existing completion action, with Mark unplayed exposed to accessibility. Its guidance appears next to the metadata when requested. Chapters appear above About, use remote Podcast 2.0 chapters when valid, and fall back to parsed description chapters. Expanded About preserves publisher formatting and timestamp seeking. Resource pills size to their labels and pack independently into two horizontally scrolling rows; one link uses one row and two links stack one per row. Links with recognized brand icons come first, followed by generic links, retaining publisher order within each group. Priority fills top then bottom before moving across (first/second in the first pair, third/fourth in the next). Recognition uses the same domain matching as the icon and tint, so lookalike hosts do not gain priority. Recognized social profiles with a parsed username show only that username beside the platform icon; accessibility still names the profile and platform. Root pages, posts without a parsed username, unknown platforms, and video/podcast/support links retain descriptive destination or publisher-context labels. Bubbles use solid platform-tinted surfaces with contrast-adjusted logos for recognized social, video, podcast and support domains (with Material purpose icons as the fallback). TikTok uses a cyan seed with a softly tinted bubble and a contrast-adjusted cyan icon; X and Threads retain monochrome theme-foreground icons rather than inventing a brand hue. Cross-promotion cards appear only after the shared detector and resolver identify another show. Try these episodes reuses the Home `FeedMediaCard` expressive poster treatment with episode-title-only text and duration on the artwork. More from keeps the compact artwork/title/date/duration row cards and lists the five latest distinct episodes vertically, excluding the current episode. Its heading is capped at two lines with ellipsis and has an arrow on a Material 3 Cookie4Sided shape; this and the Explore podcast button below the list both open the same show. The section shares the page scroll and retains related-content scroll and click analytics. Both retain show identity for accessibility. Playback, download, queue, navigation, recommendation ranking and analytics callbacks remain owned by their existing layers. Tapping download on a downloaded episode prompts for confirmation before removing local media. - `InfoViewModelAssembler` for podcast and episode ViewModel factories. - `InfoListeningProgressItem` and supporting components/sections for detail UI. Recommendation rails (`EpisodeRecommendationSection`) and episode search overlay (`PodcastInfoSearchOverlay`) protect item lists against duplicate keys using `LazyListKeyPolicy`. - Logic helpers under `logic/` and component-level formatters used by tests. `EpisodeArtworkLogic` is the Podcast Info row/sheet artwork URL (episode art, else `podcastImageUrl`, else the show image) so Missing episodes? extras without item itunes:image still show cover art. @@ -41,6 +41,7 @@ src/main/java/cx/aswin/boxlore/feature/info/ components/ logic/ EpisodeInfoSeekLogic.kt + EpisodeInfoNotesLoader.kt … sections/ ``` @@ -57,6 +58,7 @@ src/main/java/cx/aswin/boxlore/feature/info/ - Catalog, local catalog, offline lookup, RSS, download, playback, and analytics dependencies are supplied by app wiring. - Podcast/episode info emits glossary analytics via `:core:analytics` façades (no PostHog direct). - UI runs on the main thread; refresh, subscribe, lookup, and related-content work use suspend APIs. +- `EpisodeInfoNotesLoader` parses publisher HTML on a background dispatcher, fetches chapters and promotion matches independently, and cancels superseded requests. A generation guard also rejects late responses during metadata refreshes for the same episode. Invalid/empty remote chapters retain the description fallback; promotion failures do not block description or chapters. ## Persistence & identity @@ -69,7 +71,11 @@ src/main/java/cx/aswin/boxlore/feature/info/ - Unit tests live under `feature/info/src/test`. - Existing coverage includes assembler behavior, catalog port behavior and errors, offline merge logic, listening-progress mapping, duration formatting, metadata chip logic, feed grouping, selection range/order logic, toolbar logic, HTML stripping, podcast info ViewModel logic, pull-to-refresh target (RSS vs subscribed direct feed vs opted-in direct feed), subscription property/toggle preservation across pull-to-refresh and late API enrichment, episode-supplement merge/eligibility, episode list artwork fallback, and `PodcastInfoSupplementSupport` refresh / PI-only baseline / auto-opt-in / search union. Home pin persistence, capacity, and unsubscribe cleanup are covered in `:core:prefs` (`HomePinnedShowsTest`, `UserPreferencesRepositoryTest`) rather than constructing `PodcastInfoViewModel`. -- Catalog HTTP paths are covered in `:core:catalog` tests. +- `MoreFromEpisodeSelectionTest` covers newest-first ordering, current-episode exclusion before the five-item limit, duplicate IDs, stable ordering for equal dates, and short/empty catalogs. +- Catalog HTTP paths are covered in `:core:catalog` tests, including shared show-notes parsing and promotion matching. +- Link labels use the shared destination-title filter before applying publisher action text or translated labels. Generic platform names fall back to a real profile handle or an action naming the platform, rather than repeating the platform as a username. `EpisodeLinkLabelTest` covers platform aliases, generic action labels, profile/root fallbacks, and preservation of specific publisher wording. +- Cross-promotion uses a compact horizontal card on an opaque `tertiaryContainer` with matching `onTertiaryContainer` text: 96dp artwork, a confidence-aware featured-show label, a bold two-line show title, one-line publisher, and an Explore podcast pill using `tertiary`/`onTertiary`. Cached Material 3 Puffy and Cookie4Sided shapes sit behind the content at 4% and 2.5% opacity, clipped at opposite card edges and mirrored for RTL. These theme roles distinguish the featured show from neutral episode content and stay consistent in light and dark modes. The entire card is a single accessible button opening the resolved show; the pill belongs to that same action. Content height can grow for larger text and translated labels, and the full title remains available to accessibility. The show name is not repeated in a second explanatory sentence. +- `EpisodeInfoNotesLoaderTest` covers description fallback, independent failures, remote sorting/validation, cancellation, stale episode results, and same-episode metadata refreshes. `EpisodeChapterTimeTest` covers minute/hour boundaries. `EpisodeLinkRowsTest` covers single-link, two-link and uneven multi-link row placement, stable branded-first top/bottom priority, domain aliases, and lookalike-host/email fallbacks. `EpisodeLinkLabelTest` covers compact parsed profile usernames, descriptive fallback labels, publisher action text, and unknown/lookalike platform guards. `EpisodeLinkBrandIconTest` covers recognized platform domains, aliases and subdomains, and prevents lookalike hosts from receiving a platform logo. `EpisodeLinkVectorTest` protects Discord’s arc geometry from compact SVG flags that Android misreads. `EpisodeLinkPaletteTest` checks opaque containers and readable icon/text contrast in both themes, TikTok’s cyan treatment, and the monochrome X/Threads fallback. Brand-vector provenance and CC0 terms are recorded under `licenses/`. ```bash ./gradlew :feature:info:testDebugUnitTest diff --git a/feature/info/licenses/SimpleIcons-CC0.txt b/feature/info/licenses/SimpleIcons-CC0.txt new file mode 100644 index 000000000..70d4a7b67 --- /dev/null +++ b/feature/info/licenses/SimpleIcons-CC0.txt @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/feature/info/licenses/SimpleIcons.md b/feature/info/licenses/SimpleIcons.md new file mode 100644 index 000000000..51a529f51 --- /dev/null +++ b/feature/info/licenses/SimpleIcons.md @@ -0,0 +1,22 @@ +# Episode-link brand icons + +Vector paths from [Simple Icons](https://github.com/simple-icons/simple-icons), distributed under [CC0 1.0](SimpleIcons-CC0.txt). Android vectors retain the original geometry; SVG number and arc-flag separators are expanded for Android path-parser compatibility. Used to identify destinations; no affiliation or endorsement is implied. + +| Icon | Source revision | +| :--- | :--- | +| instagram | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/instagram.svg) | +| x | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/x.svg) | +| threads | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/threads.svg) | +| facebook | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/facebook.svg) | +| linkedin | [13.21.0](https://github.com/simple-icons/simple-icons/blob/13.21.0/icons/linkedin.svg) | +| bluesky | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/bluesky.svg) | +| youtube | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/youtube.svg) | +| tiktok | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/tiktok.svg) | +| twitch | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/twitch.svg) | +| reddit | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/reddit.svg) | +| discord | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/discord.svg) | +| spotify | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/spotify.svg) | +| applepodcasts | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/applepodcasts.svg) | +| patreon | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/patreon.svg) | +| kofi | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/kofi.svg) | +| buymeacoffee | [98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d](https://github.com/simple-icons/simple-icons/blob/98820a4dc8c363ca72fa2c0d294ea4a0a9bba75d/icons/buymeacoffee.svg) | diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/CrossPromotionCard.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/CrossPromotionCard.kt index 5d4f3e361..f38706c06 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/CrossPromotionCard.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/CrossPromotionCard.kt @@ -1,199 +1,116 @@ package cx.aswin.boxlore.feature.info -import android.text.Html -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Podcasts -import androidx.compose.material3.* +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.components.OptimizedImage +import cx.aswin.boxlore.core.designsystem.components.drawOutline import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.model.CrossPromotionConfidence import cx.aswin.boxlore.core.model.ResolvedCrossPromotion @Composable -fun CrossPromotionCard( - crossPromotion: ResolvedCrossPromotion, - onPodcastClick: (String) -> Unit, - modifier: Modifier = Modifier, -) { +fun CrossPromotionCard(crossPromotion: ResolvedCrossPromotion, onPodcastClick: (String) -> Unit, modifier: Modifier = Modifier) { val podcast = crossPromotion.targetPodcast ?: return - - val primaryColor = MaterialTheme.colorScheme.primary - val surfaceColor = MaterialTheme.colorScheme.surfaceContainerLow - - OutlinedCard( - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.outlinedCardColors( - containerColor = surfaceColor, - ), - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ), - modifier = - modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .expressiveClickable { onPodcastClick(podcast.id) }, + val shape = RoundedCornerShape(28.dp) + Surface( + modifier.fillMaxWidth().semantics { role = Role.Button }.expressiveClickable(shape = shape) { onPodcastClick(podcast.id) }, + shape = shape, + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - // Header label with icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Rounded.Podcasts, - contentDescription = null, - tint = primaryColor, - modifier = Modifier.size(16.dp), - ) - Text( - text = "FEATURED SHOW", - style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = GoogleSansWeight.bold, - letterSpacing = 0.5.sp, - ), - color = primaryColor, - ) - } - - Spacer(modifier = Modifier.height(6.dp)) - - // Explanation text - Text( - text = "This episode appears to be a promotional preview for the podcast below. Explore the featured show:", - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 13.sp, - lineHeight = 17.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row(Modifier.crossPromotionBackdrop(MaterialTheme.colorScheme.onTertiaryContainer).padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OptimizedImage( + podcast.imageUrl, + contentDescription = null, + proxyWidth = 320, + contentScale = ContentScale.Crop, + modifier = Modifier.size(96.dp).clip(RoundedCornerShape(20.dp)), ) - - Spacer(modifier = Modifier.height(12.dp)) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.height(12.dp)) - - // Podcast details row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Podcast Artwork - OptimizedImage( - url = podcast.imageUrl, - proxyWidth = 200, - contentDescription = podcast.title, - modifier = - Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop, + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(if (crossPromotion.confidence == CrossPromotionConfidence.HIGH) R.string.episode_info_featured_show else R.string.episode_info_featured_show_possible), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, ) - - Spacer(modifier = Modifier.width(14.dp)) - - // Metadata Column - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = podcast.title, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = GoogleSansWeight.bold, - fontSize = 15.sp, - ), - maxLines = 1, - overflow = TextOverflow.Clip, - modifier = Modifier.basicMarquee(), - ) - - Spacer(modifier = Modifier.height(2.dp)) - - val subtext = - if (podcast.genre.isNotEmpty() && podcast.genre.lowercase() != "podcast") { - "${podcast.artist} • ${podcast.genre}" - } else { - podcast.artist - } - - Text( - text = subtext, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text(podcast.title, style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (podcast.artist.isNotBlank()) { + Text(podcast.artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer, maxLines = 1, overflow = TextOverflow.Ellipsis) } - - Spacer(modifier = Modifier.width(12.dp)) - - // View Arrow Button - IconButton( - onClick = { onPodcastClick(podcast.id) }, - modifier = - Modifier - .size(36.dp) - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape, - ), + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + modifier = Modifier.padding(top = 6.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "View", - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(20.dp), - ) + Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.episode_info_explore_podcast), style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f, fill = false)) + Icon(Icons.AutoMirrored.Rounded.ArrowForward, null, modifier = Modifier.size(16.dp)) + } } } + } + } +} - // Full-width Description block (placed outside the row, stripped of HTML) - val desc = podcast.description - if (!desc.isNullOrBlank()) { - val cleanDesc = - remember(desc) { - Html.fromHtml(desc, Html.FROM_HTML_MODE_LEGACY).toString().trim() +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun Modifier.crossPromotionBackdrop(color: Color): Modifier { + val upperShape = MaterialShapes.Puffy.toShape() + val lowerShape = MaterialShapes.Cookie4Sided.toShape() + return drawWithCache { + val upperSize = 152.dp.toPx() + val lowerSize = 116.dp.toPx() + val upperOutline = upperShape.createOutline(Size(upperSize, upperSize), layoutDirection, this) + val lowerOutline = lowerShape.createOutline(Size(lowerSize, lowerSize), layoutDirection, this) + onDrawBehind { + clipRect { + scale(scaleX = if (layoutDirection == LayoutDirection.Rtl) -1f else 1f, scaleY = 1f) { + translate(left = size.width - upperSize * .55f, top = -upperSize * .4f) { + rotate(18f, pivot = Offset(upperSize / 2, upperSize / 2)) { + drawOutline(upperOutline, color, alpha = .04f) + } + } + translate(left = -lowerSize * .4f, top = size.height - lowerSize * .5f) { + rotate(-16f, pivot = Offset(lowerSize / 2, lowerSize / 2)) { + drawOutline(lowerOutline, color, alpha = .025f) + } } - if (cleanDesc.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = cleanDesc, - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, - lineHeight = 16.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) } } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeDescriptionCard.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeDescriptionCard.kt index 5eb76a183..8166494f5 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeDescriptionCard.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeDescriptionCard.kt @@ -1,513 +1,96 @@ package cx.aswin.boxlore.feature.info -import android.content.Intent -import android.net.Uri import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.component.HtmlText import cx.aswin.boxlore.core.designsystem.components.OptimizedImage import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable import cx.aswin.boxlore.core.model.Person - -// --- Data Models --- - -internal data class SocialLink( - val platform: String, - val url: String, - val brandColor: Color, - val icon: ImageVector, -) - -// --- URL Extraction & Categorization --- - -private val HREF_REGEX = Regex("""href\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) -private val URL_REGEX = Regex("""https?://[^\s<>"')\]]+""") - -// Known tracking/feed/infrastructure domains to skip entirely -private val SKIP_HOSTS = - setOf( - "podcastindex", - "feeds.", - "anchor.fm", - "podtrac", - "chartable", - "feedburner", - "podcasts.google.com", - ) - -private const val DISCORD_GG_HOST = "discord.gg" - -private fun extractHandle( - url: String, - host: String, -): String? { - return try { - val uri = java.net.URI(url) - val path = uri.path ?: return null - val segments = path.split("/").filter { it.isNotEmpty() } - if (segments.isEmpty()) return null - - when { - host.contains("youtube.com") || host.contains("youtu.be") -> extractYouTubeHandle(segments) - host.contains("reddit.com") -> extractRedditHandle(segments) - host.contains("discord.com") || host.contains(DISCORD_GG_HOST) -> extractDiscordHandle(host, segments) - host.contains("instagram.com") || - host.contains("twitter.com") || - host.contains("x.com") || - host.contains("threads.net") || - host.contains("patreon.com") || - host.contains("tiktok.com") || - host.contains("twitch.tv") || - host.contains("facebook.com") || - host.contains("fb.com") -> extractGenericSocialHandle(segments) - else -> null - } - } catch (_: Exception) { - null - } -} - -private fun extractYouTubeHandle(segments: List): String? { - val first = segments.firstOrNull() ?: return null - return when { - first.startsWith("@") -> first - first == "c" || first == "user" -> segments.getOrNull(1)?.let { "@$it" } - first == "channel" -> null - else -> if (first.length > 4 && !first.startsWith("UC")) "@$first" else null - } -} - -private fun extractRedditHandle(segments: List): String? { - if (segments.size < 2) return null - val type = segments[0] - val name = segments[1] - return when (type) { - "r" -> "r/$name" - "u", "user" -> "u/$name" - else -> null - } -} - -private fun extractDiscordHandle( - host: String, - segments: List, -): String? { - if (host.contains(DISCORD_GG_HOST)) return segments.firstOrNull() - return if (segments.firstOrNull() == "invite") segments.getOrNull(1) else segments.firstOrNull() -} - -private fun extractGenericSocialHandle(segments: List): String? { - val first = segments.firstOrNull() ?: return null - val ignore = - setOf( - "share", - "intent", - "hashtag", - "p", - "reel", - "stories", - "explore", - "home", - "tos", - "privacy", - "login", - "signup", - "messages", - "notifications", - "settings", - "search", - "about", - ) - if (ignore.contains(first.lowercase()) || first.length < 2) return null - return if (first.startsWith("@")) first else "@$first" -} - -internal fun extractSocialLinks(html: String): List { - val urls = mutableSetOf() - HREF_REGEX.findAll(html).forEach { urls.add(it.groupValues[1]) } - URL_REGEX.findAll(html).forEach { urls.add(it.value.trimEnd('.', ',', ';')) } - - return urls - .mapNotNull { url -> - val host = - try { - java.net - .URI(url) - .host - ?.lowercase() ?: "" - } catch (_: Exception) { - "" - } - - if (host.isEmpty() || SKIP_HOSTS.any { skip -> host.contains(skip) }) return@mapNotNull null - - val handle = extractHandle(url, host) - buildSocialLinkFromHost(host, url, handle) - }.distinctBy { it.url.lowercase().trim() } -} - -private fun buildSocialLinkFromHost( - host: String, - url: String, - handle: String?, -): SocialLink = getMediaSocialLink(host, url, handle) - ?: getCommunitySocialLink(host, url, handle) - ?: buildGenericWebLink(host, url) - -private fun getMediaSocialLink( - host: String, - url: String, - handle: String?, -): SocialLink? = when { - host.contains("youtube.com") || host.contains("youtu.be") -> - SocialLink(if (handle != null) "YouTube: $handle" else "YouTube", url, Color(0xFFFF0000), Icons.Rounded.PlayCircle) - host.contains("instagram.com") -> - SocialLink(if (handle != null) "Instagram: $handle" else "Instagram", url, Color(0xFFE4405F), Icons.Rounded.CameraAlt) - host.contains("twitter.com") || host.contains("x.com") -> - SocialLink(if (handle != null) "X: $handle" else "X", url, Color(0xFF1DA1F2), Icons.Rounded.Tag) - host.contains("threads.net") -> - SocialLink(if (handle != null) "Threads: $handle" else "Threads", url, Color(0xFF101010), Icons.Rounded.AlternateEmail) - host.contains("spotify.com") || host.contains("open.spotify.com") -> - SocialLink("Spotify", url, Color(0xFF1DB954), Icons.Rounded.MusicNote) - host.contains("podcasts.apple.com") -> - SocialLink("Apple Podcasts", url, Color(0xFF9933CC), Icons.Rounded.Podcasts) - else -> null -} - -private fun getCommunitySocialLink( - host: String, - url: String, - handle: String?, -): SocialLink? = when { - host.contains("patreon.com") -> - SocialLink(if (handle != null) "Patreon: $handle" else "Patreon", url, Color(0xFFF96854), Icons.Rounded.Loyalty) - host.contains("tiktok.com") -> - SocialLink(if (handle != null) "TikTok: $handle" else "TikTok", url, Color(0xFFEE1D52), Icons.Rounded.Videocam) - host.contains("facebook.com") || host.contains("fb.com") -> - SocialLink(if (handle != null) "Facebook: $handle" else "Facebook", url, Color(0xFF1877F2), Icons.Rounded.People) - host.contains("discord.com") || host.contains(DISCORD_GG_HOST) -> - SocialLink(if (handle != null) "Discord: $handle" else "Discord", url, Color(0xFF5865F2), Icons.Rounded.Forum) - host.contains("linkedin.com") -> - SocialLink("LinkedIn", url, Color(0xFF0A66C2), Icons.Rounded.Work) - host.contains("twitch.tv") -> - SocialLink(if (handle != null) "Twitch: $handle" else "Twitch", url, Color(0xFF9146FF), Icons.Rounded.Videocam) - host.contains("reddit.com") -> - SocialLink(if (handle != null) "Reddit: $handle" else "Reddit", url, Color(0xFFFF4500), Icons.Rounded.Forum) - else -> null -} - -private fun buildGenericWebLink( - host: String, - url: String, -): SocialLink { - val name = - host - .removePrefix("www.") - .split(".") - .first() - .replaceFirstChar { c -> c.uppercase() } - return SocialLink(name, url, Color(0xFF607D8B), Icons.Rounded.Language) -} - -// --- Composable --- +import cx.aswin.boxlore.core.model.ShowNotes @Composable -internal fun EpisodeDescriptionCard( - description: String, - accentColor: Color, - modifier: Modifier = Modifier, - location: String? = null, - license: String? = null, - persons: List? = null, - onSeekTo: ((Long) -> Unit)? = null, -) { +internal fun EpisodeDescriptionCard(notes: ShowNotes, modifier: Modifier = Modifier, location: String? = null, license: String? = null, persons: List? = null, onSeekTo: ((Long) -> Unit)? = null) { val context = LocalContext.current - val socialLinks = remember(description) { extractSocialLinks(description) } - var expanded by remember { mutableStateOf(false) } - val isLong = remember(description) { description.length > 500 } - val formattedDescription = - remember(description) { - formatTimestampsAsLinks(description) - } - - Surface( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .expressiveClickable(enabled = isLong) { expanded = !expanded }, - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = MaterialTheme.shapes.large, - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp) - .animateContentSize( - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - ), - ) { - // --- Cast & Crew (Person chips) --- - if (!persons.isNullOrEmpty()) { - Text( - text = "Cast & Crew", - style = MaterialTheme.typography.titleMedium, - fontWeight = GoogleSansWeight.semiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp), - ) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items(persons) { person -> - PersonChip( - person = person, - onClick = { - if (!person.href.isNullOrBlank()) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(person.href)) - context.startActivity(intent) - } - }, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - HorizontalDivider( - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - - // --- Social Links --- - if (socialLinks.isNotEmpty()) { - Text( - text = "Episode Resources", - style = MaterialTheme.typography.titleMedium, - fontWeight = GoogleSansWeight.semiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp), - ) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items(socialLinks) { link -> - SocialChip( - link = link, - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) - context.startActivity(intent) - }, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Subtle divider - HorizontalDivider( - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - - // --- Description --- - Text( - text = "About this episode", - style = MaterialTheme.typography.titleMedium, - fontWeight = GoogleSansWeight.semiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp), - ) - - Box( - modifier = Modifier.fillMaxWidth(), - ) { + var expanded by rememberSaveable(notes.plainText) { mutableStateOf(false) } + var overflows by remember(notes.plainText) { mutableStateOf(false) } + Surface(modifier.fillMaxWidth().padding(horizontal = 16.dp), shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerLow) { + Column(Modifier.padding(20.dp).animateContentSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.episode_info_about), style = MaterialTheme.typography.titleLarge) + if (expanded) { HtmlText( - text = formattedDescription, - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = 20.sp, - ), + notes.html, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = if (expanded || !isLong) Int.MAX_VALUE else 4, modifier = Modifier.fillMaxWidth(), - onClick = { if (isLong) expanded = !expanded }, onLinkClicked = { url -> - if (url.startsWith("play-position:")) { - val seconds = url.substringAfter("play-position:").toLongOrNull() ?: 0L - onSeekTo?.invoke(seconds * 1000L) - true + val seconds = url.removePrefix("play-position:").toLongOrNull() + if (url.startsWith("play-position:") && seconds != null && notes.chapters.any { it.startTime.toLong() == seconds }) { + onSeekTo?.invoke(seconds * 1_000L) } else { - false + openEpisodeLink(context, url) } - }, + true + } + ) + } else { + Text( + notes.plainText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + onTextLayout = { overflows = it.hasVisualOverflow }, + modifier = Modifier.fillMaxWidth().expressiveClickable(enabled = overflows) { expanded = true } ) - - if (!expanded && isLong) { - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(28.dp) - .background( - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, - MaterialTheme.colorScheme.surfaceContainerLow, - ), - ), - ), - ) - } } - - if (isLong) { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .expressiveClickable(shape = RoundedCornerShape(8.dp)) { expanded = !expanded } - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = if (expanded) "Show less" else "Read more", - style = MaterialTheme.typography.labelLarge, - fontWeight = GoogleSansWeight.bold, - color = accentColor.copy(alpha = 0.9f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = if (expanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, - contentDescription = null, - tint = accentColor.copy(alpha = 0.9f), - modifier = Modifier.size(16.dp), - ) - } + if (expanded || overflows) { + FilledTonalButton(onClick = { expanded = !expanded }, shape = CircleShape) { + Text(stringResource(if (expanded) R.string.episode_info_show_less else R.string.episode_info_read_more)) + Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, modifier = Modifier.size(18.dp)) } - - // --- Metadata Footer (Location & License) --- - if (!location.isNullOrBlank() || !license.isNullOrBlank()) { - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider( - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - ) - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!location.isNullOrBlank()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.weight(1f, fill = false), - ) { - Icon( - imageVector = Icons.Rounded.LocationOn, - contentDescription = "Location", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - Text( - text = location, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - if (!license.isNullOrBlank()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.weight(1f, fill = false), - ) { - Icon( - imageVector = Icons.Rounded.Copyright, - contentDescription = "License", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - Text( - text = formatLicense(license), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + } + if (!persons.isNullOrEmpty()) { + Text(stringResource(R.string.episode_info_people), style = MaterialTheme.typography.titleSmall) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(persons) { person -> PersonChip(person) { person.href?.let { openEpisodeLink(context, it) } } } } } - } - } -} - -private fun formatTimestampsAsLinks(htmlText: String): String { - if (htmlText.isBlank()) return "" - val regex = """\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b""".toRegex() - return regex.replace(htmlText) { match -> - val hours = match.groups[1]?.value?.toIntOrNull() ?: 0 - val minutes = match.groups[2]?.value?.toIntOrNull() ?: 0 - val seconds = match.groups[3]?.value?.toIntOrNull() ?: 0 - - if (minutes < 60 && seconds < 60) { - val totalSeconds = hours * 3600 + minutes * 60 + seconds - "▶ ${match.value}" - } else { - match.value + val details = listOfNotNull(location?.takeIf(String::isNotBlank), license?.takeIf(String::isNotBlank)?.let(::formatLicense)) + if (details.isNotEmpty()) Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -622,38 +205,3 @@ internal fun PersonChip( } } } - -// --- Social Chip --- - -@Composable -private fun SocialChip( - link: SocialLink, - onClick: () -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = ExpressiveShapes.Pill, - modifier = - Modifier - .expressiveClickable(isolate = true, onClick = onClick), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = link.icon, - contentDescription = link.platform, - tint = link.brandColor, - modifier = Modifier.size(16.dp), - ) - Text( - text = link.platform, - style = MaterialTheme.typography.labelMedium, - fontWeight = GoogleSansWeight.medium, - color = link.brandColor, - ) - } - } -} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoScreen.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoScreen.kt index b8a9e36bf..5d881fa6e 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoScreen.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoScreen.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.feature.info -import android.graphics.drawable.BitmapDrawable import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -9,10 +8,7 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -20,31 +16,16 @@ 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.statusBars import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.rounded.Label -import androidx.compose.material.icons.outlined.Share -import androidx.compose.material.icons.rounded.CalendarToday -import androidx.compose.material.icons.rounded.Label -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Tag -import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -59,6 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -69,27 +51,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp -import androidx.palette.graphics.Palette -import coil.compose.AsyncImagePainter -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest import cx.aswin.boxlore.core.designsystem.components.BoxLoreLoader -import cx.aswin.boxlore.core.designsystem.components.ControlStyle import cx.aswin.boxlore.core.designsystem.components.OptimizedImage import cx.aswin.boxlore.core.designsystem.components.RemoveDownloadConfirmationDialog import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable -import kotlinx.coroutines.delay - -// Color extraction helper -private fun extractDominantColor(bitmap: android.graphics.Bitmap): Color { - val palette = Palette.from(bitmap).generate() - val vibrant = palette.vibrantSwatch?.rgb - val muted = palette.mutedSwatch?.rgb - val dominant = palette.dominantSwatch?.rgb - val colorInt = vibrant ?: muted ?: dominant ?: 0xFF6200EE.toInt() - return Color(colorInt) -} @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -118,17 +83,14 @@ fun EpisodeInfoScreen( val completedEpisodeIds by viewModel.completedEpisodeIds.collectAsState() val queuedEpisodeIds by viewModel.queuedEpisodeIds.collectAsState() val listState = rememberLazyListState() + val isMoreFromScrolling by remember { + derivedStateOf { + listState.isScrollInProgress && listState.layoutInfo.visibleItemsInfo.any { it.key == "more_from_podcast" } + } + } val context = LocalContext.current val density = LocalDensity.current - // Dynamic color extraction - var extractedColor by remember { mutableStateOf(Color.Transparent) } - val accentColor by animateColorAsState( - targetValue = if (extractedColor != Color.Transparent) extractedColor else MaterialTheme.colorScheme.primary, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "accent_color", - ) - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = @@ -214,649 +176,275 @@ fun EpisodeInfoScreen( // Horizontal padding in header val titleHorizontalPadding by animateDpAsState( - targetValue = 64.dp, + targetValue = 76.dp, animationSpec = spring(stiffness = Spring.StiffnessMedium), label = "titlePadding", ) - when (val state = uiState) { - is EpisodeInfoUiState.Loading -> { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - BoxLoreLoader.Expressive(size = 80.dp) - } - } - is EpisodeInfoUiState.Error -> { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text("Failed to load episode", color = MaterialTheme.colorScheme.error) - } - } - is EpisodeInfoUiState.Success -> { - // Color extraction - val painter = - rememberAsyncImagePainter( - model = - ImageRequest - .Builder(context) - .data(state.episode.podcastImageUrl?.ifEmpty { state.episode.imageUrl?.ifEmpty { null } }) - .allowHardware(false) - .build(), - ) - LaunchedEffect(painter.state) { - val painterState = painter.state - if (painterState is AsyncImagePainter.State.Success) { - val bitmap = (painterState.result.drawable as? BitmapDrawable)?.bitmap - if (bitmap != null) { - extractedColor = extractDominantColor(bitmap) - } + cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme { + when (val state = uiState) { + is EpisodeInfoUiState.Loading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + BoxLoreLoader.Expressive(size = 80.dp) } } - - Box(modifier = modifier.fillMaxSize()) { - // Blurred Background Header + is EpisodeInfoUiState.Error -> { Box( - modifier = - Modifier - .fillMaxWidth() - .height(collapsedHeaderHeight + 240.dp) - .graphicsLayer { - translationY = -scrollOffset * 0.5f - alpha = 1f - scrollFraction - }, + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - OptimizedImage( - url = state.episode.imageUrl?.ifEmpty { state.episode.podcastImageUrl }, - proxyWidth = 200, - contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .alpha(0.5f) - .blur(50.dp, edgeTreatment = androidx.compose.ui.draw.BlurredEdgeTreatment.Unbounded), - contentScale = ContentScale.Crop, - ) - // Gradient overlay to blend into the background + Text(androidx.compose.ui.res.stringResource(R.string.episode_info_load_error), color = MaterialTheme.colorScheme.error) + } + } + is EpisodeInfoUiState.Success -> { + Box(modifier = modifier.fillMaxSize()) { + // Blurred Background Header Box( modifier = Modifier - .fillMaxSize() - .background( - androidx.compose.ui.graphics.Brush.verticalGradient( - colors = - listOf( - androidx.compose.ui.graphics.Color.Transparent, - MaterialTheme.colorScheme.background, - ), - ), - ), - ) - } - // Content List - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = - PaddingValues( - top = collapsedHeaderHeight + 16.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + bottomContentPadding + 160.dp, // Extra for miniplayer - ), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // HERO SECTION (Artwork + Title + Podcast Link + Metadata) - item { - Column( + .fillMaxWidth() + .height(collapsedHeaderHeight + 400.dp) + .clipToBounds() + .graphicsLayer { + translationY = -scrollOffset * 0.5f + alpha = 1f - scrollFraction + }, + ) { + OptimizedImage( + url = state.episode.imageUrl?.takeIf(String::isNotBlank) ?: state.episode.podcastImageUrl, + proxyWidth = 400, + contentDescription = null, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Artwork - Box(modifier = Modifier.size(180.dp)) { - Surface( - modifier = Modifier.fillMaxSize(), - shape = MaterialTheme.shapes.extraLarge, // Match PodcastInfoScreen - shadowElevation = 8.dp, - ) { - OptimizedImage( - url = state.episode.imageUrl?.ifEmpty { null }, - proxyWidth = 600, // 180dp * ~3x density - contentDescription = state.episode.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - - if (state.episode.enclosureType?.startsWith("video/") == true) { - Surface( - shape = CircleShape, - color = Color.Black.copy(alpha = 0.55f), - modifier = - Modifier - .padding(8.dp) - .align(Alignment.TopEnd), - ) { - Box( - modifier = Modifier.padding(6.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Videocam, - contentDescription = "Video", - tint = Color.White, - modifier = Modifier.size(16.dp), - ) - } - } - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - // Episode Title - Text( - text = state.episode.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = GoogleSansWeight.bold, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Podcast Title (clickable) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .expressiveClickable { - viewModel.onPodcastLinkClicked() - onPodcastClick(state.podcastId) - }.padding(vertical = 4.dp, horizontal = 8.dp), - ) { - Text( - text = state.podcastTitle, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = GoogleSansWeight.medium, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), // shrink text, never push > off screen - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Go to podcast", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Metadata Row (Chips matching PodcastInfoScreen) - fun formatDuration(seconds: Int): String { - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" - } - - fun formatRelativeDate(timestampSeconds: Long): String { - if (timestampSeconds == 0L) return "" - val now = System.currentTimeMillis() / 1000 - val diff = now - timestampSeconds - return when { - diff < 3600 -> "${diff / 60}m ago" - diff < 86400 -> "${diff / 3600}h ago" - diff < 604800 -> "${diff / 86400}d ago" - diff < 2592000 -> "${diff / 604800}w ago" - diff < 31536000 -> "${diff / 2592000}mo ago" - else -> "${diff / 31536000}y ago" - } - } - - androidx.compose.foundation.lazy.LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - contentPadding = PaddingValues(horizontal = 0.dp), - modifier = Modifier.fillMaxWidth(), - ) { - // Video Pill - if (state.episode.enclosureType?.startsWith("video/") == true) { - item { - Surface( - shape = cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Box( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Videocam, - contentDescription = "Video", - modifier = Modifier.size(16.dp), - tint = accentColor, - ) - } - } - } - } - - // Duration Pill - item { - Surface( - shape = cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.Schedule, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = formatDuration(episodeDuration), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.medium, - ) - } - } - } - - // Date Pill - val dateText = formatRelativeDate(state.episode.publishedDate) - if (dateText.isNotEmpty()) { - item { - Surface( - shape = cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.CalendarToday, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = dateText, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.medium, - ) - } - } - } - } - - // Season/Episode Pill - val season = state.episode.seasonNumber - val episode = state.episode.episodeNumber - val seLabel = - buildString { - if (season != null && season > 0) { - append("S$season ") - } - if (episode != null && episode > 0) { - append("E$episode") - } - }.trim() - if (seLabel.isNotEmpty()) { - item { - Surface( - shape = cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.Tag, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = seLabel, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.medium, - ) - } - } - } - } - - // Type Pill - if (state.episode.episodeType != null && state.episode.episodeType != "full") { - item { - Surface( - shape = cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.AutoMirrored.Rounded.Label, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = state.episode.episodeType!!.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.medium, - ) - } - } - } - } - } - } - } - - // ACTION ROW (Play Button + Progress) - Flat design - item { - Column( + .fillMaxSize() + .alpha(0.48f) + .blur(80.dp, edgeTreatment = androidx.compose.ui.draw.BlurredEdgeTreatment.Unbounded), + contentScale = ContentScale.Crop, + ) + // Gradient overlay to blend into the background + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 16.dp), - ) { - // Prepare Progress Data - val progress = - if (state.durationMs > - 0 - ) { - (state.resumePositionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) - } else { - 0f - } - val remainingSeconds = if (state.durationMs > 0) (state.durationMs - state.resumePositionMs) / 1000 else 0 - - fun formatRemaining(totalSeconds: Long): String? { - if (totalSeconds <= 0) return null - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - return if (hours > 0) "${hours}h ${minutes}m left" else "${minutes}m left" - } - - val isPlaying = state.isPlaying - val isLiked = likedEpisodeIds.contains(state.episode.id) - val isCompleted = completedEpisodeIds.contains(state.episode.id) - - // Single Elegant Row Layout (M3 standard: actions left, FAB right) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth(), - ) { - // Action Buttons Row (Material3 Tonal) on the Left - cx.aswin.boxlore.core.designsystem.components.AdvancedPlayerControls( - isLiked = isLiked, + .fillMaxSize() + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = + listOf( + androidx.compose.ui.graphics.Color.Transparent, + androidx.compose.ui.graphics.Color.Transparent, + MaterialTheme.colorScheme.background, + ), + ), + ), + ) + } + // Content List + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + top = collapsedHeaderHeight + 16.dp, + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + bottomContentPadding + 160.dp, // Extra for miniplayer + ), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + cx.aswin.boxlore.feature.info.components.EpisodeInfoHero( + episode = state.episode, + podcastTitle = state.podcastTitle, + onPodcastClick = { + viewModel.onPodcastLinkClicked() + onPodcastClick(state.podcastId) + }, + completionState = cx.aswin.boxlore.feature.info.components.EpisodeCompletionState( + isCompleted = state.episode.id in completedEpisodeIds, + showTip = showMarkPlayedTip, + ), + onToggleCompletion = viewModel::onToggleCompletion, + modifier = Modifier.padding(horizontal = 22.dp), + onMarkPlayedTipDismissed = onMarkPlayedTipDismissed, + ) + } + item { + cx.aswin.boxlore.feature.info.components.EpisodeActionRail( + state = cx.aswin.boxlore.feature.info.components.EpisodeActionRailState( + isPlaying = state.isPlaying, + isPlaybackLoading = state.isPlaybackLoading, + isLiked = state.episode.id in likedEpisodeIds, isDownloaded = isDownloaded, isDownloading = isDownloading, - colorScheme = MaterialTheme.colorScheme, + isQueued = state.episode.id in queuedEpisodeIds, + isCompleted = state.episode.id in completedEpisodeIds, + positionMs = state.resumePositionMs, + durationMs = state.durationMs, + ), + callbacks = cx.aswin.boxlore.feature.info.components.EpisodeActionRailCallbacks( + onMainActionClick = { viewModel.onMainActionClick(entryPointContext) }, onLikeClick = { viewModel.onToggleLike(state.episode) }, onDownloadClick = { viewModel.toggleDownload(state.episode) }, - onQueueClick = { viewModel.toggleQueue() }, - style = cx.aswin.boxlore.core.designsystem.components.ControlStyle.Material3, // Circular M3 - overrideColor = accentColor, // Enforce accent color for active states - horizontalArrangement = Arrangement.spacedBy(4.dp), // Tighter spacing - showAddQueueIcon = true, - isQueued = queuedEpisodeIds.contains(state.episode.id), - showShareButton = false, - isPlayed = isCompleted, - onMarkPlayedClick = { viewModel.onToggleCompletion() }, - controlSize = 40.dp, // Smaller size to fit all 4 buttons + Play button - modifier = Modifier.wrapContentWidth(unbounded = true), // Guarantee it won't shrink - ) - - Spacer(modifier = Modifier.width(12.dp)) + onQueueClick = viewModel::toggleQueue, + ), + modifier = Modifier.padding(horizontal = 22.dp), + ) + } - // Prominent Play Button (Right) - cx.aswin.boxlore.core.designsystem.components.ExpressivePlayButton( - onClick = { viewModel.onMainActionClick(entryPointContext) }, - state = - cx.aswin.boxlore.core.designsystem.components.ExpressivePlayButtonState( - isPlaying = isPlaying, - isResume = state.resumePositionMs > 0, - isLoading = state.isPlaybackLoading, - progress = progress, - timeText = formatRemaining(remainingSeconds), - ), - accentColor = accentColor, // Use extracted album art color + // CROSS-PROMOTION CARD + state.crossPromotion?.let { crossPromo -> + item { + CrossPromotionCard( + crossPromotion = crossPromo, + onPodcastClick = onPodcastClick, modifier = Modifier - .height(56.dp) - .weight(1f), // Takes up remaining width (lots of area for Resume text) + .padding(horizontal = 16.dp), ) } } - } - // One-time mark-played tooltip - if (showMarkPlayedTip) { - item { - var tipVisible by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - delay(4000) - tipVisible = false - onMarkPlayedTipDismissed() + if (state.chapters.isNotEmpty()) { + item { + cx.aswin.boxlore.feature.info.components.EpisodeChaptersSection( + episodeId = state.episode.id, + chapters = state.chapters, + positionMs = state.resumePositionMs, + onSeekTo = viewModel::seekToPosition, + modifier = Modifier.padding(horizontal = 16.dp), + ) } - - androidx.compose.animation.AnimatedVisibility( - visible = tipVisible, - enter = - androidx.compose.animation.fadeIn( - androidx.compose.animation.core - .tween(300), - ) + - androidx.compose.animation.slideInVertically(initialOffsetY = { -it / 2 }), - exit = - androidx.compose.animation.fadeOut( - androidx.compose.animation.core - .tween(500), - ), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 24.dp, bottom = 8.dp), - // Align with the controls on the left - contentAlignment = Alignment.CenterStart, - ) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.primaryContainer, - shadowElevation = 4.dp, - ) { - Text( - text = "↑ Tap to mark completed", - style = MaterialTheme.typography.labelSmall, - fontWeight = GoogleSansWeight.bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } + } + state.showNotes?.let { notes -> + if (state.episode.description.isNotEmpty()) { + item { + EpisodeDescriptionCard( + notes = notes, + location = state.location, + license = state.license, + persons = state.episode.persons, + onSeekTo = viewModel::seekToPosition, + ) } } + if (notes.links.isNotEmpty()) { + item { EpisodeLinksSection(notes.links, state.podcastTitle) } + } } - } - - // CROSS-PROMOTION CARD - state.crossPromotion?.let { crossPromo -> - item { - CrossPromotionCard( - crossPromotion = crossPromo, - onPodcastClick = onPodcastClick, - modifier = - Modifier - .padding(horizontal = 16.dp), - ) - } - } - // DESCRIPTION CARD with Social Links - if (state.episode.description.isNotEmpty()) { - item { - EpisodeDescriptionCard( - description = state.episode.description, - accentColor = accentColor, - location = state.location, - license = state.license, - persons = state.episode.persons, - onSeekTo = viewModel::seekToPosition, - ) + // Contextual "MORE LIKE THIS" RECOMMENDATIONS SECTION -> Card + if (state.similarEpisodesLoading || state.similarEpisodes.isNotEmpty()) { + item { + cx.aswin.boxlore.feature.info.sections.EpisodeInfoMoreLikeThisCard( + state = state, + onEpisodeClick = onEpisodeClick, + ) + } } - } - // Contextual "MORE LIKE THIS" RECOMMENDATIONS SECTION -> Card - if (state.similarEpisodesLoading || state.similarEpisodes.isNotEmpty()) { - item { - cx.aswin.boxlore.feature.info.sections.EpisodeInfoMoreLikeThisCard( + // Latest episodes from this show, sharing the page's vertical scroll. + item(key = "more_from_podcast") { + cx.aswin.boxlore.feature.info.sections.EpisodeInfoMoreFromPodcastCard( state = state, + onPodcastClick = onPodcastClick, onEpisodeClick = onEpisodeClick, + onPodcastLinkClicked = viewModel::onPodcastLinkClicked, + onRelatedEpisodesScrolled = viewModel::onRelatedEpisodesScrolled, + isPageScrolling = isMoreFromScrolling, + onRelatedEpisodeClicked = viewModel::onRelatedEpisodeClicked, ) } } - - // UNIFIED "MORE FROM PODCAST" SECTION -> Card - item { - cx.aswin.boxlore.feature.info.sections.EpisodeInfoMoreFromPodcastCard( - state = state, - onPodcastClick = onPodcastClick, - onEpisodeClick = onEpisodeClick, - onPodcastLinkClicked = viewModel::onPodcastLinkClicked, - onRelatedEpisodesScrolled = viewModel::onRelatedEpisodesScrolled, - onRelatedEpisodeClicked = viewModel::onRelatedEpisodeClicked, - ) - } } - } - // HEADER OVERLAY (Back button + animated background) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(collapsedHeaderHeight) - .background(headerColor) - .statusBarsPadding(), - ) { - // Back Button - IconButton( - onClick = onBack, - modifier = Modifier.align(Alignment.CenterStart).padding(start = 4.dp), + // HEADER OVERLAY (Back button + animated background) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(collapsedHeaderHeight) + .background(headerColor) + .statusBarsPadding(), ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onSurface, + // Back Button + cx.aswin.boxlore.feature.info.components.EpisodeInfoHeaderButton( + icon = Icons.AutoMirrored.Rounded.ArrowBack, + label = androidx.compose.ui.res.stringResource(R.string.episode_info_back), + onClick = onBack, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 16.dp), ) - } - // Share Button - var showShareSheet by remember { mutableStateOf(false) } - IconButton( - onClick = { showShareSheet = true }, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 4.dp), - ) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = "Share", - tint = MaterialTheme.colorScheme.onSurface, + // Share Button + var showShareSheet by remember { mutableStateOf(false) } + cx.aswin.boxlore.feature.info.components.EpisodeInfoHeaderButton( + icon = Icons.Rounded.Share, + label = androidx.compose.ui.res.stringResource(R.string.episode_info_share), + onClick = { showShareSheet = true }, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 16.dp), ) - } - if (showShareSheet) { - val currentSuccessState = uiState as? cx.aswin.boxlore.feature.info.EpisodeInfoUiState.Success - val shareEpisode = - currentSuccessState?.episode ?: cx.aswin.boxlore.core.model.Episode( - id = episodeId, - title = episodeTitle, - description = episodeDescription, - audioUrl = episodeAudioUrl, - imageUrl = episodeImageUrl, - duration = episodeDuration, - ) - cx.aswin.boxlore.core.designsystem.components.ShareBottomSheet( - id = shareEpisode.id, - type = "episode", - title = shareEpisode.title, - subtitle = podcastTitle, - imageUrl = shareEpisode.imageUrl ?: shareEpisode.podcastImageUrl, - onDismissRequest = { showShareSheet = false }, - durationMs = shareEpisode.duration * 1000L, - currentPositionMs = currentSuccessState?.resumePositionMs ?: 0L, - showTimestampOption = false, - onShare = { _, _, timestamp, target -> - cx.aswin.boxlore.core.designsystem.share.ShareManager.shareEpisode( - context = context, - episode = shareEpisode, - podcastTitle = podcastTitle, - timestampMs = timestamp, - target = target, + if (showShareSheet) { + val currentSuccessState = uiState as? cx.aswin.boxlore.feature.info.EpisodeInfoUiState.Success + val shareEpisode = + currentSuccessState?.episode ?: cx.aswin.boxlore.core.model.Episode( + id = episodeId, + title = episodeTitle, + description = episodeDescription, + audioUrl = episodeAudioUrl, + imageUrl = episodeImageUrl, + duration = episodeDuration, ) - }, - ) + cx.aswin.boxlore.core.designsystem.components.ShareBottomSheet( + id = shareEpisode.id, + type = "episode", + title = shareEpisode.title, + subtitle = podcastTitle, + imageUrl = shareEpisode.imageUrl ?: shareEpisode.podcastImageUrl, + onDismissRequest = { showShareSheet = false }, + durationMs = shareEpisode.duration * 1000L, + currentPositionMs = currentSuccessState?.resumePositionMs ?: 0L, + showTimestampOption = false, + onShare = { _, _, timestamp, target -> + cx.aswin.boxlore.core.designsystem.share.ShareManager.shareEpisode( + context = context, + episode = shareEpisode, + podcastTitle = podcastTitle, + timestampMs = timestamp, + target = target, + ) + }, + ) + } } + + // FLOATING TITLE - physically moves from body to header + Text( + text = state.episode.title, + fontSize = titleFontSize, + fontWeight = GoogleSansWeight.bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = titleHorizontalPadding) + .graphicsLayer { + translationY = titleTranslationY + alpha = titleAlpha + }, + ) } + } - // FLOATING TITLE - physically moves from body to header - Text( - text = episodeTitle, - fontSize = titleFontSize, - fontWeight = GoogleSansWeight.bold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = titleHorizontalPadding) - .graphicsLayer { - translationY = titleTranslationY - alpha = titleAlpha - }, + if (showRemoveDownloadDialog) { + RemoveDownloadConfirmationDialog( + episodeTitle = (uiState as? EpisodeInfoUiState.Success)?.episode?.title ?: episodeTitle, + onConfirm = viewModel::confirmDownloadRemoval, + onDismiss = viewModel::dismissDownloadRemoval, ) - } } - - if (showRemoveDownloadDialog) { - RemoveDownloadConfirmationDialog( - episodeTitle = (uiState as? EpisodeInfoUiState.Success)?.episode?.title ?: episodeTitle, - onConfirm = viewModel::confirmDownloadRemoval, - onDismiss = viewModel::dismissDownloadRemoval, - ) } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoViewModel.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoViewModel.kt index 0cd5a09d9..b93f86f80 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoViewModel.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeInfoViewModel.kt @@ -48,6 +48,8 @@ sealed interface EpisodeInfoUiState { val license: String? = null, val crossPromotion: cx.aswin.boxlore.core.model.ResolvedCrossPromotion? = null, val crossPromoLoading: Boolean = false, + val showNotes: cx.aswin.boxlore.core.model.ShowNotes? = null, + val chapters: List = emptyList(), ) : EpisodeInfoUiState data object Error : EpisodeInfoUiState @@ -66,6 +68,22 @@ class EpisodeInfoViewModel( ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(EpisodeInfoUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private val crossPromotionResolver = cx.aswin.boxlore.core.catalog.crosspromo.CrossPromotionResolver(podcastRepository) + private val notesLoader = cx.aswin.boxlore.feature.info.logic.EpisodeInfoNotesLoader( + scope = viewModelScope, + chapters = cx.aswin.boxlore.core.catalog.ChapterRepository::getChapters, + promotion = { episode, hostId, hostTitle, notes -> + val result = cx.aswin.boxlore.core.catalog.crosspromo.CrossPromotionDetector().detect(episode, hostTitle, notes) + val name = result.extractedShowName + if (result.isCrossPromotion && name != null) { + crossPromotionResolver.resolve(name, hostId, notes.links)?.let { target -> + cx.aswin.boxlore.core.model.ResolvedCrossPromotion(name, result.confidence, target, result.matchedIndicators) + } + } else { + null + } + }, + ) // Observe liked episodes val likedEpisodeIds = @@ -196,6 +214,7 @@ class EpisodeInfoViewModel( return } + notesLoader.cancel() viewModelScope.launch { _uiState.value = EpisodeInfoUiState.Loading try { @@ -346,8 +365,6 @@ class EpisodeInfoViewModel( location = initialLocation, license = initialLicense, ) - - detectCrossPromotion(currentEpisode, finalPodcastTitle) } } else { // Fetch network episode anyway to ensure we have any extra metadata @@ -371,6 +388,7 @@ class EpisodeInfoViewModel( } } + detectCrossPromotion(currentEpisode, finalPodcastTitle) loadRelatedAndSimilar(episodeId, finalPodcastId, finalEpisodeTitle, finalPodcastTitle, finalEpisodeDescription) } catch (e: Exception) { e.printStackTrace() @@ -403,10 +421,7 @@ class EpisodeInfoViewModel( // Use getEpisodesPaginated which is the correct method used elsewhere val page = podcastRepository.getEpisodesPaginated(podcastId, 15, 0, "newest") android.util.Log.d("EpisodeInfo", "Fetched ${page.episodes.size} episodes, genre: $genre") - val relatedEps = - page.episodes - .filter { it.id != episodeId } - .take(10) + val relatedEps = cx.aswin.boxlore.feature.info.logic.selectMoreFromEpisodes(page.episodes, episodeId) val currentSuccess = _uiState.value as? EpisodeInfoUiState.Success if (currentSuccess != null && currentSuccess.episode.id == episodeId) { @@ -687,62 +702,23 @@ class EpisodeInfoViewModel( .trackEpisodeInfoScreenSession(props) } - private fun detectCrossPromotion( - episode: Episode, - hostPodcastTitle: String, - ) { - viewModelScope.launch { - try { - val currentSuccess = _uiState.value as? EpisodeInfoUiState.Success - if (currentSuccess != null) { - _uiState.value = currentSuccess.copy(crossPromoLoading = true) - } + private fun detectCrossPromotion(episode: Episode, hostPodcastTitle: String) { + val current = _uiState.value as? EpisodeInfoUiState.Success ?: return + if (current.episode.id != episode.id) return + _uiState.value = current.copy(crossPromoLoading = true, crossPromotion = null) + notesLoader.load( + episode, + current.podcastId, + hostPodcastTitle, + onNotes = { notes -> updateEpisodeExtras(episode.id) { it.copy(showNotes = notes) } }, + onChapters = { chapters -> updateEpisodeExtras(episode.id) { it.copy(chapters = chapters) } }, + onPromotion = { promotion -> updateEpisodeExtras(episode.id) { it.copy(crossPromotion = promotion, crossPromoLoading = false) } }, + ) + } - val detector = - cx.aswin.boxlore.core.catalog.crosspromo - .CrossPromotionDetector() - val result = detector.detect(episode, hostPodcastTitle) - val extractedName = result.extractedShowName - - if (result.isCrossPromotion && extractedName != null) { - android.util.Log.d("EpisodeInfo", "Cross promotion detected: $extractedName") - val resolver = - cx.aswin.boxlore.core.catalog.crosspromo - .CrossPromotionResolver(podcastRepository) - val targetPodcast = resolver.resolve(extractedName) - - val finalSuccess = _uiState.value as? EpisodeInfoUiState.Success - if (finalSuccess != null && finalSuccess.episode.id == episode.id) { - _uiState.value = - finalSuccess.copy( - crossPromoLoading = false, - crossPromotion = - cx.aswin.boxlore.core.model.ResolvedCrossPromotion( - extractedShowName = extractedName, - confidence = result.confidence, - targetPodcast = targetPodcast, - matchedIndicators = result.matchedIndicators, - ), - ) - } - } else { - val finalSuccess = _uiState.value as? EpisodeInfoUiState.Success - if (finalSuccess != null && finalSuccess.episode.id == episode.id) { - _uiState.value = - finalSuccess.copy( - crossPromoLoading = false, - crossPromotion = null, - ) - } - } - } catch (e: Exception) { - android.util.Log.e("EpisodeInfo", "Error detecting cross promotion", e) - val finalSuccess = _uiState.value as? EpisodeInfoUiState.Success - if (finalSuccess != null && finalSuccess.episode.id == episode.id) { - _uiState.value = finalSuccess.copy(crossPromoLoading = false) - } - } - } + private fun updateEpisodeExtras(episodeId: String, update: (EpisodeInfoUiState.Success) -> EpisodeInfoUiState.Success) { + val current = _uiState.value as? EpisodeInfoUiState.Success ?: return + if (current.episode.id == episodeId) _uiState.value = update(current) } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIcon.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIcon.kt new file mode 100644 index 000000000..483ae2922 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIcon.kt @@ -0,0 +1,34 @@ +package cx.aswin.boxlore.feature.info + +import java.util.Locale + +private val episodeLinkBrands = mapOf( + "instagram.com" to R.drawable.ic_link_instagram, + "x.com" to R.drawable.ic_link_x, + "twitter.com" to R.drawable.ic_link_x, + "threads.net" to R.drawable.ic_link_threads, + "threads.com" to R.drawable.ic_link_threads, + "facebook.com" to R.drawable.ic_link_facebook, + "fb.com" to R.drawable.ic_link_facebook, + "linkedin.com" to R.drawable.ic_link_linkedin, + "bsky.app" to R.drawable.ic_link_bluesky, + "youtube.com" to R.drawable.ic_link_youtube, + "youtu.be" to R.drawable.ic_link_youtube, + "tiktok.com" to R.drawable.ic_link_tiktok, + "twitch.tv" to R.drawable.ic_link_twitch, + "reddit.com" to R.drawable.ic_link_reddit, + "discord.com" to R.drawable.ic_link_discord, + "discord.gg" to R.drawable.ic_link_discord, + "open.spotify.com" to R.drawable.ic_link_spotify, + "podcasts.apple.com" to R.drawable.ic_link_applepodcasts, + "patreon.com" to R.drawable.ic_link_patreon, + "ko-fi.com" to R.drawable.ic_link_kofi, + "buymeacoffee.com" to R.drawable.ic_link_buymeacoffee, +) + +internal fun episodeLinkBrandIcon(host: String): Int? { + val normalized = host.lowercase(Locale.ROOT).trimEnd('.') + return episodeLinkBrands.entries.firstOrNull { (domain, _) -> + normalized == domain || normalized.endsWith(".$domain") + }?.value +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkPalette.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkPalette.kt new file mode 100644 index 000000000..a3c80c957 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkPalette.kt @@ -0,0 +1,46 @@ +package cx.aswin.boxlore.feature.info + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import kotlin.math.max +import kotlin.math.min + +internal data class EpisodeLinkPalette(val container: Color, val icon: Color) + +private val platformColors = mapOf( + R.drawable.ic_link_instagram to Color(0xFFFF0069), + R.drawable.ic_link_facebook to Color(0xFF0866FF), + R.drawable.ic_link_linkedin to Color(0xFF0A66C2), + R.drawable.ic_link_bluesky to Color(0xFF1185FE), + R.drawable.ic_link_youtube to Color(0xFFFF0000), + R.drawable.ic_link_tiktok to Color(0xFF25F4EE), + R.drawable.ic_link_twitch to Color(0xFF9146FF), + R.drawable.ic_link_reddit to Color(0xFFFF4500), + R.drawable.ic_link_discord to Color(0xFF5865F2), + R.drawable.ic_link_spotify to Color(0xFF1ED760), + R.drawable.ic_link_applepodcasts to Color(0xFF9933CC), + R.drawable.ic_link_kofi to Color(0xFFFF6433), + R.drawable.ic_link_buymeacoffee to Color(0xFFFFDD00), +) + +internal fun episodeLinkPalette(icon: Int, surface: Color, onSurface: Color): EpisodeLinkPalette { + // Monochrome brands use the theme foreground so black logos also work in dark mode. + val seed = platformColors[icon] ?: onSurface + val container = lerp(surface, seed, 0.10f) + return EpisodeLinkPalette(container, readablePlatformTint(seed, container, onSurface)) +} + +private fun readablePlatformTint(seed: Color, container: Color, foreground: Color): Color { + for (step in 0..10) { + val tint = lerp(seed, foreground, step / 10f) + if (linkColorContrast(tint, container) >= 3f) return tint + } + return foreground +} + +private fun linkColorContrast(first: Color, second: Color): Float { + val firstLuminance = first.luminance() + val secondLuminance = second.luminance() + return (max(firstLuminance, secondLuminance) + 0.05f) / (min(firstLuminance, secondLuminance) + 0.05f) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkRows.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkRows.kt new file mode 100644 index 000000000..f8a29edf6 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinkRows.kt @@ -0,0 +1,12 @@ +package cx.aswin.boxlore.feature.info + +import cx.aswin.boxlore.core.model.EpisodeLink + +/** Branded links first, in publisher order within each group; fill top/bottom before moving across. */ +internal fun episodeLinkRows(links: List): List> { + val (branded, other) = links.partition { episodeLinkBrandIcon(it.host) != null } + val ordered = branded + other + return (0 until minOf(2, ordered.size)).map { row -> + ordered.filterIndexed { index, _ -> index % 2 == row } + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinksSection.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinksSection.kt new file mode 100644 index 000000000..fb48a4f23 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/EpisodeLinksSection.kt @@ -0,0 +1,180 @@ +package cx.aswin.boxlore.feature.info + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Article +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.AlternateEmail +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.MailOutline +import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Podcasts +import androidx.compose.material.icons.rounded.VolunteerActivism +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.catalog.shownotes.EpisodeLinkTitles +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind + +@Composable +internal fun EpisodeLinksSection(links: List, showName: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + val rows = remember(links) { episodeLinkRows(links) } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.episode_info_links), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 20.dp)) + // Rows scroll together, but pack independently so shorter bubbles leave no column gaps. + Column(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + row.forEach { link -> + EpisodeLinkPill(link, showName, Modifier.widthIn(max = 300.dp)) { + openEpisodeLink(context, link.url) + } + } + } + } + } + } +} + +@Composable +private fun EpisodeLinkPill(link: EpisodeLink, showName: String, modifier: Modifier, onClick: () -> Unit) { + val label = episodeLinkLabel(link, showName) + val profile = episodeLinkProfileHandle(link) + val accessibleLabel = if (profile != null) { + stringResource(R.string.episode_info_link_view_on, profile, link.platform ?: link.host) + } else { + "$label, ${link.host}" + } + val brand = episodeLinkBrandIcon(link.host) + val colors = MaterialTheme.colorScheme + val palette = brand?.let { episodeLinkPalette(it, colors.surfaceContainer, colors.onSurface) } + Surface( + shape = CircleShape, + color = palette?.container ?: colors.surfaceContainer, + modifier = modifier.semantics { contentDescription = accessibleLabel } + .expressiveClickable(shape = CircleShape, onClick = onClick), + ) { + Row(Modifier.heightIn(min = 48.dp).padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + EpisodeLinkIcon(link, palette?.icon ?: colors.onSurfaceVariant) + Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + Icon(Icons.AutoMirrored.Rounded.OpenInNew, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(14.dp)) + } + } +} + +@Composable +private fun EpisodeLinkIcon(link: EpisodeLink, tint: Color) { + val brand = episodeLinkBrandIcon(link.host) + if (brand != null) { + Icon(painterResource(brand), null, tint = tint, modifier = Modifier.size(20.dp)) + } else { + val icon = when (link.kind) { + EpisodeLinkKind.WEBSITE -> Icons.Rounded.Language + EpisodeLinkKind.ARTICLE -> Icons.AutoMirrored.Rounded.Article + EpisodeLinkKind.VIDEO -> Icons.Rounded.PlayCircleOutline + EpisodeLinkKind.SOCIAL -> Icons.Rounded.AlternateEmail + EpisodeLinkKind.SUPPORT -> Icons.Rounded.VolunteerActivism + EpisodeLinkKind.PODCAST -> Icons.Rounded.Podcasts + EpisodeLinkKind.COMMUNITY -> Icons.Rounded.Forum + EpisodeLinkKind.EMAIL -> Icons.Rounded.MailOutline + } + Icon(icon, null, tint = tint, modifier = Modifier.size(20.dp)) + } +} + +@Composable +internal fun episodeLinkLabel(link: EpisodeLink, showName: String): String { + episodeLinkActionTitle(link)?.let { return it } + val spec = episodeLinkLabelSpec(link, showName) + return stringResource(spec.resource, *spec.arguments.toTypedArray()) +} + +internal data class EpisodeLinkLabelSpec(val resource: Int, val arguments: List) + +internal fun episodeLinkProfileHandle(link: EpisodeLink): String? { + if (link.kind != EpisodeLinkKind.SOCIAL || episodeLinkBrandIcon(link.host) == null) return null + return link.handle?.trim()?.takeIf(String::isNotBlank) +} + +internal fun episodeLinkActionTitle(link: EpisodeLink): String? { + if (episodeLinkProfileHandle(link) != null) return null + return EpisodeLinkTitles.destinationTitle(link.title, link.platform) + ?.takeIf { Regex("(?i)^(read|watch|visit|listen|support|view|open|email)\\b").containsMatchIn(it) } +} + +internal fun episodeLinkLabelSpec(link: EpisodeLink, showName: String): EpisodeLinkLabelSpec { + val destination = link.copy(title = EpisodeLinkTitles.destinationTitle(link.title, link.platform)) + return destinationLabelSpec(destination, showName) +} + +private fun destinationLabelSpec(link: EpisodeLink, showName: String): EpisodeLinkLabelSpec = when (link.kind) { + EpisodeLinkKind.WEBSITE -> labelSpec(R.string.episode_info_link_visit, link.title ?: link.host) + EpisodeLinkKind.ARTICLE -> labelSpec(R.string.episode_info_link_read, link.title ?: link.host) + EpisodeLinkKind.VIDEO -> videoLabel(link) + EpisodeLinkKind.SOCIAL -> socialLabel(link) + EpisodeLinkKind.SUPPORT -> communityLabel(link, showName, R.string.episode_info_link_support) + EpisodeLinkKind.PODCAST -> podcastLabel(link) + EpisodeLinkKind.COMMUNITY -> communityLabel(link, showName, R.string.episode_info_link_join) + EpisodeLinkKind.EMAIL -> labelSpec(R.string.episode_info_link_email, link.host) +} + +private fun communityLabel(link: EpisodeLink, showName: String, resource: Int) = labelSpec(resource, link.title ?: link.handle ?: showName, link.platform ?: link.host) + +private fun labelSpec(resource: Int, vararg arguments: String) = EpisodeLinkLabelSpec(resource, arguments.toList()) +private fun videoLabel(link: EpisodeLink): EpisodeLinkLabelSpec = link.title?.let { labelSpec(R.string.episode_info_link_watch, it) } + ?: labelSpec(R.string.episode_info_link_watch_on, link.platform ?: link.host) +private fun podcastLabel(link: EpisodeLink): EpisodeLinkLabelSpec = link.title?.let { labelSpec(R.string.episode_info_link_listen, it) } + ?: labelSpec(R.string.episode_info_link_listen_on, link.platform ?: link.host) +private fun socialLabel(link: EpisodeLink): EpisodeLinkLabelSpec { + episodeLinkProfileHandle(link)?.let { return labelSpec(R.string.episode_info_link_profile, it) } + val destination = link.title ?: link.handle?.takeIf(String::isNotBlank) + return if (destination == null) { + labelSpec(R.string.episode_info_link_open_on, link.platform ?: link.host) + } else { + labelSpec(R.string.episode_info_link_view_on, destination, link.platform ?: link.host) + } +} + +internal fun openEpisodeLink(context: Context, url: String) { + val uri = Uri.parse(url) + val scheme = uri.scheme?.lowercase(java.util.Locale.ROOT) + if (scheme !in setOf("https", "http", "mailto")) return + if (scheme != "mailto" && uri.host.isNullOrBlank()) return + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, R.string.episode_info_link_unavailable, Toast.LENGTH_SHORT).show() + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeActionRail.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeActionRail.kt index 19a07ff92..3f51ab8c6 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeActionRail.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeActionRail.kt @@ -1,85 +1,65 @@ package cx.aswin.boxlore.feature.info.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd import androidx.compose.material.icons.automirrored.rounded.PlaylistAddCheck -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.DownloadDone -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.DownloadDone +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.TonalToggleButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.designsystem.components.BoxLoreLoader -import cx.aswin.boxlore.core.designsystem.components.ExpressivePlayButton -import cx.aswin.boxlore.core.designsystem.components.ExpressivePlayButtonState -import cx.aswin.boxlore.core.designsystem.components.OptimizedImage -import cx.aswin.boxlore.core.designsystem.theme.ExpressiveMotion -import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.core.designsystem.theme.contrastColor -import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable -import kotlinx.coroutines.delay +import cx.aswin.boxlore.feature.info.R internal data class EpisodeActionRailState( - val title: String, - val imageUrl: String?, val isPlaying: Boolean, val isPlaybackLoading: Boolean, - val isResume: Boolean, val isLiked: Boolean, val isDownloaded: Boolean, val isDownloading: Boolean, val isQueued: Boolean, val isCompleted: Boolean, - val progress: Float, - val remainingTimeText: String?, + val positionMs: Long, + val durationMs: Long, ) internal data class EpisodeActionRailCallbacks( @@ -87,297 +67,156 @@ internal data class EpisodeActionRailCallbacks( val onLikeClick: () -> Unit, val onDownloadClick: () -> Unit, val onQueueClick: () -> Unit, - val onMarkPlayedClick: () -> Unit, ) @Composable -internal fun EpisodeActionRail( - state: EpisodeActionRailState, - callbacks: EpisodeActionRailCallbacks, - accentColor: Color, - showMarkPlayedTip: Boolean, - onMarkPlayedTipDismissed: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ExpressivePlayButton( - onClick = callbacks.onMainActionClick, - state = ExpressivePlayButtonState( - isPlaying = state.isPlaying, - isLoading = state.isPlaybackLoading, - isResume = state.isResume, - progress = state.progress, - timeText = state.remainingTimeText, - ), - accentColor = accentColor, - modifier = Modifier - .fillMaxWidth() - .height(60.dp), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - space = 8.dp, - alignment = Alignment.CenterHorizontally, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - EpisodeToolbarActionButton( - isActive = state.isCompleted, - activeIcon = Icons.Rounded.CheckCircle, - inactiveIcon = Icons.Outlined.CheckCircle, - contentDescription = if (state.isCompleted) "Mark unplayed" else "Mark played", - onClick = callbacks.onMarkPlayedClick, - ) - EpisodeToolbarActionButton( - isActive = state.isLiked, - activeIcon = Icons.Filled.Favorite, - inactiveIcon = Icons.Outlined.FavoriteBorder, - contentDescription = if (state.isLiked) "Unlike" else "Like", - onClick = callbacks.onLikeClick, - ) - EpisodeToolbarActionButton( - isActive = state.isDownloaded, - isLoading = state.isDownloading, - activeIcon = Icons.Outlined.DownloadDone, - inactiveIcon = Icons.Outlined.Download, - contentDescription = if (state.isDownloaded) "Remove download" else "Download", - onClick = callbacks.onDownloadClick, - ) - EpisodeToolbarActionButton( - isActive = state.isQueued, - activeIcon = Icons.AutoMirrored.Rounded.PlaylistAddCheck, - inactiveIcon = Icons.AutoMirrored.Rounded.PlaylistAdd, - contentDescription = if (state.isQueued) "Remove from queue" else "Add to queue", - onClick = callbacks.onQueueClick, - ) +internal fun EpisodeActionRail(state: EpisodeActionRailState, callbacks: EpisodeActionRailCallbacks, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Independent actions form one connected row; Play has more room than the tonal toggles. + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) { + EpisodeSecondaryActions(state, callbacks) + EpisodePlayButton(state, callbacks.onMainActionClick, Modifier.weight(2.1f)) } - MarkPlayedCoachmark( - visible = showMarkPlayedTip, - onDismissed = onMarkPlayedTipDismissed, - ) + if (state.positionMs > 0 && state.durationMs > 0 && !state.isCompleted) EpisodeProgressSummary(state.positionMs, state.durationMs) } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun EpisodeToolbarActionButton( - isActive: Boolean, - activeIcon: ImageVector, - inactiveIcon: ImageVector, - contentDescription: String, - onClick: () -> Unit, - isLoading: Boolean = false, -) { - val containerColor by animateColorAsState( - targetValue = if (isActive) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerLow - }, - label = "episode_toolbar_container", - ) - val contentColor by animateColorAsState( - targetValue = if (isActive) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - label = "episode_toolbar_content", +private fun EpisodePlayButton(state: EpisodeActionRailState, onClick: () -> Unit, modifier: Modifier) { + val playLabel = stringResource( + when { + state.isPlaybackLoading -> R.string.episode_info_loading + state.isPlaying -> R.string.episode_info_pause + state.isCompleted -> R.string.episode_info_play + state.positionMs > 0 -> R.string.episode_info_resume + else -> R.string.episode_info_play + } ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(48.dp) - .semantics { - this.contentDescription = contentDescription - if (isLoading) stateDescription = "Loading" + val textStyle = MaterialTheme.typography.titleSmall.copy(fontWeight = GoogleSansWeight.bold) + val textWidth = rememberTextMeasurer().measure(playLabel, style = textStyle).size.width + val density = LocalDensity.current + BoxWithConstraints(modifier.height(56.dp)) { + // On narrow screens or with larger text, retain a labelled icon rather than clipping a word. + val showLabel = with(density) { textWidth.toDp() + 48.dp <= maxWidth } + Button( + onClick = onClick, + enabled = !state.isPlaybackLoading, + shapes = ButtonDefaults.shapes( + shape = RoundedCornerShape(topStart = 9.dp, topEnd = 28.dp, bottomEnd = 28.dp, bottomStart = 9.dp), + pressedShape = RoundedCornerShape(20.dp), + ), + colors = ButtonDefaults.buttonColors( + disabledContainerColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + ), + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth().height(56.dp) + .semantics { contentDescription = playLabel }, + ) { + Box(contentAlignment = Alignment.Center) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + if (state.isPlaybackLoading) { + BoxLoreLoader.CircularWavy(size = 24.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, null, modifier = Modifier.size(26.dp)) + } + if (showLabel) Text(playLabel, style = textStyle, maxLines = 1) + } } - .expressiveClickable( - enabled = !isLoading, - shape = ExpressiveShapes.Pill, - onClick = onClick, - ) - .background(containerColor, ExpressiveShapes.Pill), - ) { - if (isLoading) { - BoxLoreLoader.CircularWavy( - size = 22.dp, - color = contentColor, - ) - } else { - Icon( - imageVector = if (isActive) activeIcon else inactiveIcon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(22.dp), - ) } } } @Composable -private fun MarkPlayedCoachmark( - visible: Boolean, - onDismissed: () -> Unit, -) { - var isVisible by remember(visible) { mutableStateOf(visible) } - LaunchedEffect(isVisible) { - if (isVisible) { - delay(4_000) - isVisible = false - onDismissed() - } - } - AnimatedVisibility( - visible = isVisible, - enter = fadeIn() + slideInVertically(initialOffsetY = { -it / 2 }), - exit = fadeOut() + scaleOut(targetScale = 0.92f), - ) { - Surface( - shape = ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) { - Text( - text = "Tip: tap the check to mark this episode complete", - style = MaterialTheme.typography.labelMedium, - fontWeight = GoogleSansWeight.bold, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), - ) - } - } +private fun EpisodeListeningProgress(positionMs: Long, durationMs: Long) { + val progress by animateFloatAsState((positionMs.toFloat() / durationMs).coerceIn(0f, 1f), label = "episode_listening_progress") + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().height(6.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeCap = StrokeCap.Round, + gapSize = 4.dp, + drawStopIndicator = {}, + ) } @Composable -internal fun CompactEpisodeActionRail( - state: EpisodeActionRailState, - callbacks: EpisodeActionRailCallbacks, - accentColor: Color, - visible: Boolean, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = visible, - modifier = modifier, - enter = fadeIn(ExpressiveMotion.SleekFadeSpec) + - slideInVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = 200f, - ), - ) { -it / 3 } + - scaleIn(animationSpec = ExpressiveMotion.SpatialLargeSpring, initialScale = 0.96f), - exit = fadeOut(ExpressiveMotion.SleekFadeSpec) + - slideOutVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = 200f, - ), - ) { -it / 3 } + - scaleOut(animationSpec = ExpressiveMotion.SpatialLargeSpring, targetScale = 0.96f), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shadowElevation = 10.dp, - tonalElevation = 6.dp, - ) { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OptimizedImage( - url = state.imageUrl, - proxyWidth = 144, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .clip(MaterialTheme.shapes.large), - contentScale = ContentScale.Crop, - ) - Spacer(Modifier.width(10.dp)) - Text( - text = state.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = GoogleSansWeight.bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - CompactPlayButton( - isPlaying = state.isPlaying, - isLoading = state.isPlaybackLoading, - progress = state.progress, - accentColor = accentColor, - onClick = callbacks.onMainActionClick, - ) - } +private fun RowScope.EpisodeSecondaryActions(state: EpisodeActionRailState, callbacks: EpisodeActionRailCallbacks) { + RailAction( + state.isLiked, + if (state.isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + stringResource(if (state.isLiked) R.string.episode_info_unlike else R.string.episode_info_like), + callbacks.onLikeClick, + modifier = Modifier.weight(1f), + leading = true, + ) + RailAction( + state.isDownloaded, + if (state.isDownloaded) Icons.Rounded.DownloadDone else Icons.Rounded.Download, + stringResource(if (state.isDownloaded) R.string.episode_info_remove_download else R.string.episode_info_download), + callbacks.onDownloadClick, + modifier = Modifier.weight(1f), + loading = state.isDownloading, + ) + RailAction( + state.isQueued, + if (state.isQueued) Icons.AutoMirrored.Rounded.PlaylistAddCheck else Icons.AutoMirrored.Rounded.PlaylistAdd, + stringResource(if (state.isQueued) R.string.episode_info_remove_queue else R.string.episode_info_add_queue), + callbacks.onQueueClick, + modifier = Modifier.weight(1f), + ) +} + +@Composable +private fun EpisodeProgressSummary(positionMs: Long, durationMs: Long) { + val lessMinute = stringResource(R.string.episode_info_less_minute) + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.episode_info_played_time, formatEpisodeDuration((positionMs / 1_000).toInt()).ifEmpty { lessMinute }), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.episode_info_time_left, formatEpisodeDuration(((durationMs - positionMs).coerceAtLeast(0) / 1_000).toInt()).ifEmpty { lessMinute }), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.End) } + EpisodeListeningProgress(positionMs, durationMs) } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun CompactPlayButton( - isPlaying: Boolean, - isLoading: Boolean, - progress: Float, - accentColor: Color, - onClick: () -> Unit, -) { - val contentDescription = if (isPlaying) "Pause" else "Play" - Surface( - modifier = Modifier - .padding(start = 4.dp) - .size(52.dp) - .semantics { - this.contentDescription = contentDescription - if (isLoading) stateDescription = "Loading" - } - .expressiveClickable( - enabled = !isLoading, - shape = CircleShape, - isolate = true, - onClick = onClick, - ), - shape = CircleShape, - color = accentColor, - contentColor = accentColor.contrastColor(), +private fun RailAction(active: Boolean, icon: ImageVector, actionLabel: String, onClick: () -> Unit, modifier: Modifier, leading: Boolean = false, loading: Boolean = false) { + val downloading = stringResource(R.string.episode_info_downloading) + val shapes = if (leading) { + ButtonGroupDefaults.connectedLeadingButtonShapes( + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 9.dp, bottomEnd = 9.dp, bottomStart = 28.dp), + pressedShape = RoundedCornerShape(18.dp), + ) + } else { + ButtonGroupDefaults.connectedMiddleButtonShapes( + shape = RoundedCornerShape(9.dp), + pressedShape = RoundedCornerShape(18.dp), + ) + } + TonalToggleButton( + checked = active, + onCheckedChange = { onClick() }, + enabled = !loading, + shapes = shapes, + colors = ToggleButtonDefaults.tonalToggleButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + contentPadding = PaddingValues(0.dp), + modifier = modifier.height(56.dp).semantics { + contentDescription = actionLabel + if (loading) stateDescription = downloading + }, ) { - Box(contentAlignment = Alignment.Center) { - if (isLoading) { - BoxLoreLoader.CircularWavy( - size = 27.dp, - color = accentColor.contrastColor(), - ) - } else { - Icon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = null, - modifier = Modifier.size(27.dp), - ) - } - if (progress > 0f) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .height(3.dp), - color = accentColor.contrastColor(alpha = 0.62f), - trackColor = Color.Transparent, - drawStopIndicator = {}, - ) - } - } + if (loading) { + BoxLoreLoader.CircularWavy(size = 22.dp, color = MaterialTheme.colorScheme.primary) + } else { + Icon(icon, null, modifier = Modifier.size(23.dp)) } } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeChaptersSection.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeChaptersSection.kt new file mode 100644 index 000000000..0e152cf53 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeChaptersSection.kt @@ -0,0 +1,95 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.model.Chapter +import cx.aswin.boxlore.feature.info.R +import java.util.Locale + +@Composable +internal fun EpisodeChaptersSection(episodeId: String, chapters: List, positionMs: Long, onSeekTo: (Long) -> Unit, modifier: Modifier = Modifier) { + var expanded by rememberSaveable(episodeId) { mutableStateOf(false) } + val visible = if (expanded) chapters else chapters.take(4) + val active = if (positionMs > 0) chapters.indexOfLast { it.startTime * 1_000 <= positionMs } else -1 + Column(modifier.fillMaxWidth().animateContentSize(), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.episode_info_chapters), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + Text(pluralStringResource(R.plurals.episode_info_chapter_count, chapters.size, chapters.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + visible.forEachIndexed { index, chapter -> + val time = chapterTime(chapter.startTime) + val action = stringResource(R.string.episode_info_jump_chapter, chapter.title, time) + val current = stringResource(R.string.episode_info_current_chapter) + val shape = RoundedCornerShape( + topStart = if (index == 0) 24.dp else 5.dp, + topEnd = if (index == 0) 24.dp else 5.dp, + bottomStart = if (index == visible.lastIndex) 24.dp else 5.dp, + bottomEnd = if (index == visible.lastIndex) 24.dp else 5.dp + ) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = shape, + modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) { + contentDescription = action + if (active == index) stateDescription = current + } + .expressiveClickable(shape = shape) { onSeekTo((chapter.startTime * 1_000).toLong()) } + ) { + Row(Modifier.heightIn(min = 64.dp).padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = RoundedCornerShape(10.dp), + color = if (active == index) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (active == index) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) { + Text(time, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 9.dp, vertical = 6.dp)) + } + Text(chapter.title, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Icon(Icons.AutoMirrored.Rounded.ArrowForward, null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + if (chapters.size > 4) { + TextButton(onClick = { expanded = !expanded }) { + Text(stringResource(if (expanded) R.string.episode_info_fewer_chapters else R.string.episode_info_all_chapters)) + } + } + } +} + +internal fun chapterTime(seconds: Double): String { + val total = seconds.toLong().coerceAtLeast(0) + return if (total >= 3_600) { + String.format(Locale.ROOT, "%d:%02d:%02d", total / 3_600, total / 60 % 60, total % 60) + } else { + String.format(Locale.ROOT, "%02d:%02d", total / 60, total % 60) + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeCompletionPill.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeCompletionPill.kt new file mode 100644 index 000000000..1ff512ff1 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeCompletionPill.kt @@ -0,0 +1,50 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.info.R + +internal data class EpisodeCompletionState(val isCompleted: Boolean, val showTip: Boolean = false) + +@Composable +internal fun EpisodeCompletionPill(isCompleted: Boolean, onToggleCompletion: () -> Unit) { + val label = stringResource(if (isCompleted) R.string.episode_info_played else R.string.episode_info_mark_played) + val action = stringResource(if (isCompleted) R.string.episode_info_mark_unplayed else R.string.episode_info_mark_played) + FilterChip( + selected = isCompleted, + onClick = onToggleCompletion, + label = { Text(label, style = MaterialTheme.typography.labelMedium, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) }, + leadingIcon = { Icon(if (isCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.CheckCircleOutline, null, Modifier.size(18.dp)) }, + shape = CircleShape, + border = null, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.heightIn(min = 40.dp).semantics { + contentDescription = action + stateDescription = label + }, + ) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeExpandableTitle.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeExpandableTitle.kt new file mode 100644 index 000000000..8705651aa --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeExpandableTitle.kt @@ -0,0 +1,92 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.feature.info.R + +@Composable +internal fun EpisodeExpandableTitle(episodeId: String, title: String) { + var expanded by rememberSaveable(episodeId, title) { mutableStateOf(false) } + val style = MaterialTheme.typography.headlineMedium.copy(fontWeight = GoogleSansWeight.bold) + val measurer = rememberTextMeasurer() + BoxWithConstraints(Modifier.fillMaxWidth()) { + val textConstraints = Constraints(maxWidth = constraints.maxWidth) + val collapsed = measurer.measure(title, style, maxLines = 3, overflow = TextOverflow.Ellipsis, constraints = textConstraints) + if (!collapsed.hasVisualOverflow) { + Text(title, style = style, modifier = Modifier.fillMaxWidth().semantics { heading() }) + } else { + val layout = if (expanded) measurer.measure(title, style, constraints = textConstraints) else collapsed + val lastLineStart = layout.getLineStart(layout.lineCount - 1) + Column( + Modifier.fillMaxWidth().semantics(mergeDescendants = true) { + heading() + contentDescription = title + }.expressiveClickable { expanded = !expanded }, + ) { + // The first two collapsed lines retain the entire width. Only the final line makes room for the control. + Text( + title.substring(0, lastLineStart).trimEnd(), + style = style, + maxLines = if (expanded) Int.MAX_VALUE else 2, + modifier = Modifier.fillMaxWidth().clearAndSetSemantics {}, + ) + EpisodeTitleLastLine(title.substring(lastLineStart), style, expanded) { expanded = !expanded } + } + } + } +} + +@Composable +private fun EpisodeTitleLastLine(text: String, style: TextStyle, expanded: Boolean, onToggle: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text, + style = style, + maxLines = if (expanded) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).clearAndSetSemantics {}, + ) + IconButton(onClick = onToggle, modifier = Modifier.align(Alignment.Bottom).size(48.dp)) { + Box(Modifier.size(32.dp).background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f), CircleShape), contentAlignment = Alignment.Center) { + Icon( + if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + stringResource(if (expanded) R.string.episode_info_show_less else R.string.episode_info_full_title), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHeaderButton.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHeaderButton.kt new file mode 100644 index 000000000..82fd448e3 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHeaderButton.kt @@ -0,0 +1,24 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +internal fun EpisodeInfoHeaderButton(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton( + onClick = onClick, + modifier = modifier.size(48.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon(icon, label, modifier = Modifier.size(24.dp)) + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHero.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHero.kt index e1a43103b..cc51f0b6a 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHero.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeInfoHero.kt @@ -1,281 +1,163 @@ package cx.aswin.boxlore.feature.info.components -import androidx.compose.foundation.background +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.rounded.Label -import androidx.compose.material.icons.rounded.CalendarToday -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Tag -import androidx.compose.material.icons.rounded.Videocam import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.designsystem.components.OptimizedImage -import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable import cx.aswin.boxlore.core.model.Episode - -@Composable -internal fun EpisodeArtworkBackdrop( - imageUrl: String?, - scrollOffset: Float, - collapseFraction: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = - modifier - .graphicsLayer { - translationY = -scrollOffset * 0.5f - alpha = 1f - collapseFraction - }, - ) { - OptimizedImage( - url = imageUrl, - proxyWidth = 200, - contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .alpha(0.5f) - .blur(50.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), - contentScale = ContentScale.Crop, - ) - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, - MaterialTheme.colorScheme.background, - ), - ), - ), - ) - } -} +import cx.aswin.boxlore.feature.info.R +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.delay @Composable internal fun EpisodeInfoHero( episode: Episode, podcastTitle: String, - accentColor: Color, - collapseFraction: Float, onPodcastClick: () -> Unit, + completionState: EpisodeCompletionState, + onToggleCompletion: () -> Unit, modifier: Modifier = Modifier, + onMarkPlayedTipDismissed: () -> Unit = {}, ) { - androidx.compose.foundation.layout.Column( - modifier = - modifier - .fillMaxWidth() - .graphicsLayer { - alpha = 1f - collapseFraction * 0.18f - translationY = -collapseFraction * 20.dp.toPx() - }, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(204.dp), - contentAlignment = Alignment.Center, + Column(modifier.fillMaxWidth().animateContentSize()) { + Surface( + modifier = Modifier.size(200.dp).align(Alignment.CenterHorizontally), + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Box( - modifier = Modifier.size(184.dp), - ) { - Surface( + Box(Modifier.fillMaxSize()) { + OptimizedImage( + url = episode.imageUrl?.takeIf(String::isNotBlank) ?: episode.podcastImageUrl, + proxyWidth = 640, + contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shadowElevation = 6.dp, - ) { - OptimizedImage( - url = - episode.imageUrl?.takeIf(String::isNotBlank) - ?: episode.podcastImageUrl, - proxyWidth = 640, - contentDescription = episode.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - if (episode.enclosureType?.startsWith("video/") == true) { - Surface( - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 2.dp), - shape = ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - shadowElevation = 6.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Rounded.Videocam, contentDescription = null, modifier = Modifier.size(18.dp)) - Text("Video", style = MaterialTheme.typography.labelLarge, fontWeight = GoogleSansWeight.bold) - } - } - } + ) + EpisodeArtworkTags(episode, Modifier.align(Alignment.TopStart).fillMaxWidth().padding(12.dp)) } } - - Text( - text = episode.title, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = GoogleSansWeight.extraBold, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 24.dp), - ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(20.dp)) Row( - modifier = - Modifier - .expressiveClickable(onClick = onPodcastClick) - .padding(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).expressiveClickable(onClick = onPodcastClick), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = podcastTitle, + podcastTitle, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = GoogleSansWeight.bold, + color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) - Spacer(Modifier.width(2.dp)) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Open podcast", - tint = accentColor, - modifier = Modifier.size(20.dp), - ) + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, stringResource(R.string.episode_info_open_show, podcastTitle), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + } + EpisodeExpandableTitle(episode.id, episode.title) + Spacer(Modifier.height(8.dp)) + EpisodeMetadata(episode, completionState.isCompleted, onToggleCompletion) + if (completionState.showTip) { + Text(stringResource(R.string.episode_info_mark_tip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + LaunchedEffect(episode.id) { + delay(4_000) + onMarkPlayedTipDismissed() + } } - Spacer(Modifier.height(12.dp)) - EpisodeMetadataChipsRow(episode) } } -private data class EpisodeMetadataChip( - val label: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, -) - @Composable -private fun EpisodeMetadataChipsRow(episode: Episode) { - val metadata = - buildList { - if (episode.enclosureType?.startsWith("video/") == true) { - add(EpisodeMetadataChip("Video", Icons.Rounded.Videocam)) - } - formatEpisodeDuration(episode.duration) - .takeIf(String::isNotBlank) - ?.let { add(EpisodeMetadataChip(it, Icons.Rounded.Schedule)) } - formatRelativeDate(episode.publishedDate)?.let { - add(EpisodeMetadataChip(it, Icons.Rounded.CalendarToday)) - } - formatSeasonAndEpisode(episode)?.let { - add(EpisodeMetadataChip(it, Icons.Rounded.Tag)) - } - episode.episodeType - ?.takeUnless { it.equals("full", ignoreCase = true) } - ?.let { - add( - EpisodeMetadataChip( - it.replaceFirstChar(Char::uppercase), - Icons.AutoMirrored.Rounded.Label, - ), - ) - } +private fun EpisodeMetadata(episode: Episode, isCompleted: Boolean, onToggleCompletion: () -> Unit) { + val date = remember(episode.publishedDate) { + episode.publishedDate.takeIf { it > 0L }?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it * 1_000L)) } + } + val number = episodeNumberLabel(episode) + val metadata = listOfNotNull(date, formatEpisodeDuration(episode.duration).takeIf(String::isNotBlank), number).joinToString(" · ") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (metadata.isNotEmpty()) { + Text( + metadata, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = Modifier.weight(1f).clipToBounds().basicMarquee( + iterations = Int.MAX_VALUE, + initialDelayMillis = 2_500, + repeatDelayMillis = 2_000, + spacing = MarqueeSpacing(32.dp), + velocity = 24.dp, + ), + ) + } else { + Spacer(Modifier.weight(1f)) } + EpisodeCompletionPill(isCompleted, onToggleCompletion) + } +} - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - contentPadding = PaddingValues(horizontal = 0.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items(metadata) { item -> - Surface( - shape = ExpressiveShapes.Pill, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = item.label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.bold, - ) - } - } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EpisodeArtworkTags(episode: Episode, modifier: Modifier = Modifier) { + FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + val type = when (episode.episodeType?.lowercase()) { + "bonus" -> R.string.episode_info_bonus + "trailer" -> R.string.episode_info_trailer + else -> null } + type?.let { QuietMetadataChip(stringResource(it)) } + if (episode.enclosureType?.startsWith("video/") == true) QuietMetadataChip(stringResource(R.string.episode_info_video)) } } -private fun formatRelativeDate(timestampSeconds: Long): String? { - if (timestampSeconds <= 0L) return null - val difference = ((System.currentTimeMillis() / 1000L) - timestampSeconds).coerceAtLeast(0L) +@Composable +private fun episodeNumberLabel(episode: Episode): String? { + val season = episode.seasonNumber ?: 0 + val number = episode.episodeNumber ?: 0 return when { - difference < 3_600L -> "${difference / 60L}m ago" - difference < 86_400L -> "${difference / 3_600L}h ago" - difference < 604_800L -> "${difference / 86_400L}d ago" - difference < 2_592_000L -> "${difference / 604_800L}w ago" - difference < 31_536_000L -> "${difference / 2_592_000L}mo ago" - else -> "${difference / 31_536_000L}y ago" + season > 0 && number > 0 -> stringResource(R.string.episode_info_season_episode, season, number) + number > 0 -> stringResource(R.string.episode_info_number, number) + season > 0 -> stringResource(R.string.episode_info_season, season) + else -> null } } -private fun formatSeasonAndEpisode(episode: Episode): String? = buildString { - episode.seasonNumber?.takeIf { it > 0 }?.let { append("S$it") } - episode.episodeNumber?.takeIf { it > 0 }?.let { - if (isNotEmpty()) append(" ") - append("E$it") +@Composable +private fun QuietMetadataChip(label: String) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, shadowElevation = 1.dp) { + Text(label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) } -}.ifBlank { null } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeRecommendationSection.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeRecommendationSection.kt index 11d0ab663..750b54aee 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeRecommendationSection.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/EpisodeRecommendationSection.kt @@ -1,16 +1,11 @@ package cx.aswin.boxlore.feature.info.components -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,13 +13,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -32,20 +25,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cx.aswin.boxlore.core.designsystem.components.OptimizedImage +import cx.aswin.boxlore.core.designsystem.components.FeedMediaCard +import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation +import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardSkeleton import cx.aswin.boxlore.core.designsystem.list.LazyListKeyPolicy -import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable import cx.aswin.boxlore.core.designsystem.theme.m3Shimmer import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.feature.info.R internal data class EpisodeRecommendationState( val title: String, @@ -58,14 +53,9 @@ internal data class EpisodeRecommendationState( ) internal object EpisodeRecommendationSectionLogic { - fun filterEpisodes(episodes: List): List = - LazyListKeyPolicy.deduplicateById(episodes) { it.id } + fun filterEpisodes(episodes: List): List = LazyListKeyPolicy.deduplicateById(episodes) { it.id } - fun shouldRender( - isLoading: Boolean, - hasEpisodes: Boolean, - emptyMessage: String?, - ): Boolean = isLoading || hasEpisodes || emptyMessage != null + fun shouldRender(isLoading: Boolean, hasEpisodes: Boolean, emptyMessage: String?): Boolean = isLoading || hasEpisodes || emptyMessage != null } @Composable @@ -76,203 +66,65 @@ internal fun EpisodeRecommendationSection( onHeaderClick: (() -> Unit)? = null, onScrollStarted: (() -> Unit)? = null, ) { - val distinctEpisodes = remember(state.episodes) { - EpisodeRecommendationSectionLogic.filterEpisodes(state.episodes) - } - if (!EpisodeRecommendationSectionLogic.shouldRender( - isLoading = state.loading, - hasEpisodes = distinctEpisodes.isNotEmpty(), - emptyMessage = state.emptyMessage, - ) - ) { - return - } - + val episodes = remember(state.episodes) { EpisodeRecommendationSectionLogic.filterEpisodes(state.episodes) } + if (!EpisodeRecommendationSectionLogic.shouldRender(state.loading, episodes.isNotEmpty(), state.emptyMessage)) return val listState = rememberLazyListState() LaunchedEffect(listState.isScrollInProgress) { if (listState.isScrollInProgress) onScrollStarted?.invoke() } - - OutlinedCard( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraLarge, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Column(modifier = Modifier.padding(vertical = 16.dp)) { - EpisodeRecommendationHeader( - state = state, - onHeaderClick = onHeaderClick, + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp) + .then(if (onHeaderClick != null) Modifier.expressiveClickable(onClick = onHeaderClick) else Modifier) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(state.icon, null, tint = state.accentColor, modifier = Modifier.size(22.dp)) + Text( + state.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - Spacer(Modifier.height(14.dp)) - LazyRow( - state = listState, - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - when { - state.loading -> items(4) { RecommendationSkeleton() } - distinctEpisodes.isNotEmpty() -> - items( - distinctEpisodes, - key = { LazyListKeyPolicy.safeKey(it.id, prefix = "rec_ep") } - ) { episode -> - ExpressiveEpisodeCard( - episode = episode, - imageUrl = - episode.imageUrl?.ifBlank { episode.podcastImageUrl } - ?: state.fallbackImageUrl, - onClick = { onEpisodeClick(episode) }, - ) - } - state.emptyMessage != null -> - item { - Text( - text = state.emptyMessage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 24.dp, horizontal = 4.dp), - ) - } - } - } + if (onHeaderClick != null) Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.primary) } - } -} - -@Composable -private fun EpisodeRecommendationHeader( - state: EpisodeRecommendationState, - onHeaderClick: (() -> Unit)?, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .then( - if (onHeaderClick != null) { - Modifier.expressiveClickable( - shape = MaterialTheme.shapes.large, - onClick = onHeaderClick, + LazyRow(state = listState, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + when { + state.loading -> items(4) { RecommendationSkeleton() } + episodes.isNotEmpty() -> items(episodes, key = { LazyListKeyPolicy.safeKey(it.id, prefix = "rec_ep") }) { episode -> + val showName = episode.podcastTitle?.takeIf(String::isNotBlank) + val accessibleTitle = showName?.let { stringResource(R.string.episode_info_episode_from_show, episode.title, it) } ?: episode.title + FeedMediaCard( + imageUrl = episode.imageUrl?.takeIf(String::isNotBlank) ?: episode.podcastImageUrl?.takeIf(String::isNotBlank) ?: state.fallbackImageUrl.orEmpty(), + title = episode.title, + subtitle = null, + onClick = { onEpisodeClick(episode) }, + presentation = FeedMediaCardPresentation.ExpressivePoster, + modifier = Modifier.width(160.dp).semantics { contentDescription = accessibleTitle }, + imageChrome = { + val duration = formatEpisodeDuration(episode.duration) + if (duration.isNotBlank()) { + Surface( + modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Text(duration, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) + } + } + }, ) - } else { - Modifier - }, - ).padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = state.icon, - contentDescription = null, - tint = state.accentColor, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(10.dp)) - Text( - text = state.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = GoogleSansWeight.extraBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (onHeaderClick != null) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Open ${state.title}", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ExpressiveEpisodeCard( - episode: Episode, - imageUrl: String?, - onClick: () -> Unit, -) { - val durationText = formatEpisodeDuration(episode.duration) - OutlinedCard( - modifier = - Modifier - .width(160.dp) - .expressiveClickable( - shape = MaterialTheme.shapes.large, - onClick = onClick, - ), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Column { - Box( - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f), - ) { - OptimizedImage( - url = imageUrl, - proxyWidth = 400, - contentDescription = episode.title, - modifier = - Modifier - .matchParentSize() - .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)), - contentScale = ContentScale.Crop, - ) - if (durationText.isNotEmpty()) { - Surface( - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(6.dp), - shape = MaterialTheme.shapes.small, - color = Color.Black.copy(alpha = 0.6f), - contentColor = Color.White, - ) { - Text( - text = durationText, - style = MaterialTheme.typography.labelSmall, - fontWeight = GoogleSansWeight.medium, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } } - } - Column( - modifier = - Modifier - .padding(10.dp) - .heightIn(min = 58.dp), - ) { - Text( - text = episode.title, - style = - MaterialTheme.typography.titleMedium.copy( - fontSize = 13.sp, - lineHeight = 17.sp, - ), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(6.dp)) - episode.podcastTitle?.takeIf(String::isNotBlank)?.let { podcastTitle -> + state.emptyMessage != null -> item { Text( - text = podcastTitle, - style = MaterialTheme.typography.bodySmall, + state.emptyMessage, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } } @@ -282,30 +134,9 @@ private fun ExpressiveEpisodeCard( @Composable private fun RecommendationSkeleton() { - val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh - val highlightColor = MaterialTheme.colorScheme.surfaceContainerHighest - Column(modifier = Modifier.width(160.dp)) { - Box( - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(MaterialTheme.shapes.large) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - Spacer(Modifier.height(10.dp)) - repeat(2) { index -> - Box( - modifier = - Modifier - .fillMaxWidth(if (index == 0) 1f else 0.72f) - .height(13.dp) - .clip(ExpressiveShapes.Pill) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - Spacer(Modifier.height(5.dp)) - } + val base = MaterialTheme.colorScheme.surfaceContainerHigh + val highlight = MaterialTheme.colorScheme.surfaceContainerHighest + FeedMediaCardSkeleton(Modifier.width(160.dp)) { modifier -> + androidx.compose.foundation.layout.Box(modifier.background(base).m3Shimmer(base, highlight)) } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/MoreFromEpisodeSection.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/MoreFromEpisodeSection.kt new file mode 100644 index 000000000..4eca9719c --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/MoreFromEpisodeSection.kt @@ -0,0 +1,153 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.components.OptimizedImage +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.designsystem.theme.m3Shimmer +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.feature.info.R +import java.text.DateFormat +import java.util.Date + +@Composable +internal fun MoreFromEpisodeSection( + showName: String, + episodes: List, + loading: Boolean, + fallbackImageUrl: String?, + onEpisodeClick: (Episode) -> Unit, + onShowClick: () -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + MoreFromHeader(showName, onShowClick) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + when { + loading -> repeat(5) { index -> MoreFromRowSkeleton(moreFromRowShape(index, 5)) } + episodes.isNotEmpty() -> episodes.forEachIndexed { index, episode -> + MoreFromEpisodeRow(episode, showName, fallbackImageUrl, moreFromRowShape(index, episodes.size)) { onEpisodeClick(episode) } + } + else -> Text( + stringResource(R.string.episode_info_no_more), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } + FilledTonalButton( + onClick = onShowClick, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Text(stringResource(R.string.episode_info_explore_podcast)) + Icon(Icons.AutoMirrored.Rounded.ArrowForward, null, modifier = Modifier.padding(start = 8.dp).size(20.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MoreFromHeader(showName: String, onShowClick: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.episode_info_more_from, showName), + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + val shape = MaterialShapes.Cookie4Sided.toShape() + FilledTonalIconButton( + onClick = onShowClick, + modifier = Modifier.size(48.dp), + shapes = IconButtonDefaults.shapes(shape = shape, pressedShape = shape), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon(Icons.AutoMirrored.Rounded.ArrowForward, stringResource(R.string.episode_info_open_show, showName), Modifier.size(24.dp)) + } + } +} + +private fun moreFromRowShape(index: Int, count: Int) = RoundedCornerShape( + topStart = if (index == 0) 24.dp else 6.dp, + topEnd = if (index == 0) 24.dp else 6.dp, + bottomStart = if (index == count - 1) 24.dp else 6.dp, + bottomEnd = if (index == count - 1) 24.dp else 6.dp, +) + +@Composable +private fun MoreFromEpisodeRow(episode: Episode, showName: String, fallbackImageUrl: String?, shape: RoundedCornerShape, onClick: () -> Unit) { + val date = remember(episode.publishedDate) { + episode.publishedDate.takeIf { it > 0L }?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it * 1_000L)) } + } + val metadata = listOfNotNull(date, formatEpisodeDuration(episode.duration).takeIf(String::isNotBlank)).joinToString(" · ") + val accessibleTitle = stringResource(R.string.episode_info_episode_from_show, episode.title, showName) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth().semantics { contentDescription = accessibleTitle } + .expressiveClickable(shape = shape, onClick = onClick), + ) { + Row(Modifier.heightIn(min = 88.dp).padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OptimizedImage( + url = episode.imageUrl?.takeIf(String::isNotBlank) ?: episode.podcastImageUrl?.takeIf(String::isNotBlank) ?: fallbackImageUrl, + proxyWidth = 240, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(60.dp).clip(RoundedCornerShape(14.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(episode.title, style = MaterialTheme.typography.titleSmall, fontWeight = GoogleSansWeight.medium, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (metadata.isNotEmpty()) Text(metadata, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun MoreFromRowSkeleton(shape: RoundedCornerShape) { + val base = MaterialTheme.colorScheme.surfaceContainer + val highlight = MaterialTheme.colorScheme.surfaceContainerHighest + Box(Modifier.fillMaxWidth().height(88.dp).clip(shape).background(base).m3Shimmer(base, highlight)) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoader.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoader.kt new file mode 100644 index 000000000..98ab45b58 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoader.kt @@ -0,0 +1,59 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser +import cx.aswin.boxlore.core.model.Chapter +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.ResolvedCrossPromotion +import cx.aswin.boxlore.core.model.ShowNotes +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Latest request owns every publication, including metadata refreshes for the same episode. */ +internal class EpisodeInfoNotesLoader( + private val scope: CoroutineScope, + private val chapters: suspend (String) -> List, + private val promotion: suspend (Episode, String, String, ShowNotes) -> ResolvedCrossPromotion?, + private val parserDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + private var generation = 0 + private var job: Job? = null + + fun load(episode: Episode, hostId: String, hostTitle: String, onNotes: (ShowNotes) -> Unit, onChapters: (List) -> Unit, onPromotion: (ResolvedCrossPromotion?) -> Unit) { + cancel() + val request = generation + job = scope.launch { + val notes = withContext(parserDispatcher) { ShowNotesParser.parse(episode.description, durationSeconds = episode.duration) } + if (request != generation) return@launch + onNotes(notes) + onChapters(notes.chapters) + launch { + val remote = episode.chaptersUrl?.takeIf(String::isNotBlank)?.let { url -> recover { chapters(url) } }.orEmpty() + val valid = remote.filter { it.startTime.isFinite() && it.startTime >= 0 && (episode.duration <= 0 || it.startTime < episode.duration) } + .distinctBy { it.startTime }.sortedBy { it.startTime } + if (request == generation && valid.isNotEmpty()) onChapters(valid) + } + launch { + val result = recover { promotion(episode, hostId, hostTitle, notes) } + if (request == generation) onPromotion(result) + } + } + } + + fun cancel() { + generation++ + job?.cancel() + } + + private suspend fun recover(block: suspend () -> T): T? = try { + block() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelection.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelection.kt new file mode 100644 index 000000000..cf8b011b0 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelection.kt @@ -0,0 +1,10 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.model.Episode + +/** The latest five distinct episodes from the show page, excluding the episode being viewed. */ +internal fun selectMoreFromEpisodes(episodes: List, currentEpisodeId: String): List = episodes + .filter { it.id != currentEpisodeId } + .sortedByDescending { it.publishedDate } + .distinctBy { it.id } + .take(5) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/sections/EpisodeInfoRecommendationCards.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/sections/EpisodeInfoRecommendationCards.kt index fd8b7a961..b8f84864b 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/sections/EpisodeInfoRecommendationCards.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/sections/EpisodeInfoRecommendationCards.kt @@ -1,207 +1,31 @@ package cx.aswin.boxlore.feature.info.sections -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Subscriptions -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cx.aswin.boxlore.core.designsystem.components.OptimizedImage -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable -import cx.aswin.boxlore.core.designsystem.theme.m3Shimmer +import androidx.compose.ui.res.stringResource import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.feature.info.EpisodeInfoUiState +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.components.EpisodeRecommendationSection +import cx.aswin.boxlore.feature.info.components.EpisodeRecommendationState +import cx.aswin.boxlore.feature.info.components.MoreFromEpisodeSection @Composable -internal fun EpisodeInfoMoreLikeThisCard( - state: EpisodeInfoUiState.Success, - onEpisodeClick: (Episode) -> Unit, -) { - androidx.compose.material3.OutlinedCard( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - androidx.compose.material3.CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, +internal fun EpisodeInfoMoreLikeThisCard(state: EpisodeInfoUiState.Success, onEpisodeClick: (Episode) -> Unit) { + EpisodeRecommendationSection( + state = EpisodeRecommendationState( + title = stringResource(R.string.episode_info_more_like_this), + icon = Icons.Rounded.AutoAwesome, + episodes = state.similarEpisodes, + loading = state.similarEpisodesLoading, + accentColor = MaterialTheme.colorScheme.primary, + fallbackImageUrl = null, ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.extraLarge, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = "More Like This", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = GoogleSansWeight.bold, - letterSpacing = (-0.1).sp, - ), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - val similarListState = rememberLazyListState() - LazyRow( - state = similarListState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - if (state.similarEpisodesLoading) { - items(4) { - val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh - val highlightColor = MaterialTheme.colorScheme.surfaceContainerHighest - - Column( - modifier = Modifier.width(120.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = - Modifier - .size(120.dp) - .clip(MaterialTheme.shapes.medium) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(14.dp) - .clip(MaterialTheme.shapes.small) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - Spacer(modifier = Modifier.height(4.dp)) - Box( - modifier = - Modifier - .fillMaxWidth(0.7f) - .height(14.dp) - .clip(MaterialTheme.shapes.small) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - } - } - } else { - items(state.similarEpisodes) { episode -> - androidx.compose.material3.OutlinedCard( - shape = RoundedCornerShape(16.dp), - colors = - androidx.compose.material3.CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = - Modifier - .width(140.dp) - .expressiveClickable { - onEpisodeClick(episode) - }, - ) { - Column { - OptimizedImage( - url = episode.imageUrl?.ifEmpty { episode.podcastImageUrl }, - proxyWidth = 300, - contentDescription = episode.title, - modifier = - Modifier - .size(140.dp) - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), - contentScale = ContentScale.Crop, - ) - Column( - modifier = Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = episode.title, - style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = GoogleSansWeight.bold, - lineHeight = 14.sp, - ), - color = MaterialTheme.colorScheme.onSurface, - minLines = 2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val podTitle = episode.podcastTitle - if (!podTitle.isNullOrEmpty()) { - Text( - text = podTitle, - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 11.sp, - fontWeight = GoogleSansWeight.medium, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - } - } - } - } - } + onEpisodeClick = onEpisodeClick, + ) } @Composable @@ -211,178 +35,24 @@ internal fun EpisodeInfoMoreFromPodcastCard( onEpisodeClick: (Episode) -> Unit, onPodcastLinkClicked: () -> Unit, onRelatedEpisodesScrolled: () -> Unit, + isPageScrolling: Boolean, onRelatedEpisodeClicked: () -> Unit, ) { - androidx.compose.material3.OutlinedCard( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - androidx.compose.material3.CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.extraLarge, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .expressiveClickable { - onPodcastLinkClicked() - onPodcastClick(state.podcastId) - }.padding(horizontal = 20.dp) - .padding(bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.Subscriptions, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = "More from ${state.podcastTitle}", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = GoogleSansWeight.bold, - letterSpacing = (-0.1).sp, - ), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Go to podcast", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Horizontal episodes row - val relatedListState = rememberLazyListState() - LaunchedEffect(relatedListState.isScrollInProgress) { - if (relatedListState.isScrollInProgress) { - onRelatedEpisodesScrolled() - } - } - LazyRow( - state = relatedListState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - if (state.relatedEpisodesLoading) { - // Skeleton loaders - items(4) { - val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh - val highlightColor = MaterialTheme.colorScheme.surfaceContainerHighest - - Column( - modifier = Modifier.width(120.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Skeleton artwork with shimmer - Box( - modifier = - Modifier - .size(120.dp) - .clip(MaterialTheme.shapes.medium) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Skeleton text with shimmer - Box( - modifier = - Modifier - .fillMaxWidth() - .height(14.dp) - .clip(MaterialTheme.shapes.small) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - Spacer(modifier = Modifier.height(4.dp)) - Box( - modifier = - Modifier - .fillMaxWidth(0.7f) - .height(14.dp) - .clip(MaterialTheme.shapes.small) - .background(baseColor) - .m3Shimmer(baseColor, highlightColor), - ) - } - } - } else if (state.relatedEpisodes.isNotEmpty()) { - items(state.relatedEpisodes) { episode -> - androidx.compose.material3.OutlinedCard( - shape = RoundedCornerShape(16.dp), - colors = - androidx.compose.material3.CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = - Modifier - .width(140.dp) - .expressiveClickable { - onRelatedEpisodeClicked() - onEpisodeClick(episode) - }, - ) { - Column { - // Episode Artwork - OptimizedImage( - url = episode.imageUrl?.ifEmpty { state.episode.podcastImageUrl }, - proxyWidth = 300, // 140dp thumbnails - contentDescription = episode.title, - modifier = - Modifier - .size(140.dp) - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), - contentScale = ContentScale.Crop, - ) - - // Title in card footer - minLines for even sizing - Text( - text = episode.title, - style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = GoogleSansWeight.semiBold, - lineHeight = 14.sp, - ), - color = MaterialTheme.colorScheme.onSurface, - minLines = 3, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(12.dp), - ) - } - } - } - } else { - // No episodes message - item { - Text( - text = "No other episodes available", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 16.dp), - ) - } - } - } - } + LaunchedEffect(isPageScrolling, state.episode.id) { + if (isPageScrolling && state.relatedEpisodes.isNotEmpty()) onRelatedEpisodesScrolled() } + MoreFromEpisodeSection( + showName = state.podcastTitle, + episodes = state.relatedEpisodes, + loading = state.relatedEpisodesLoading, + fallbackImageUrl = state.episode.podcastImageUrl, + onEpisodeClick = { + onRelatedEpisodeClicked() + onEpisodeClick(it) + }, + onShowClick = { + onPodcastLinkClicked() + onPodcastClick(state.podcastId) + }, + ) } diff --git a/feature/info/src/main/res/drawable/ic_link_applepodcasts.xml b/feature/info/src/main/res/drawable/ic_link_applepodcasts.xml new file mode 100644 index 000000000..3ad3d1936 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_applepodcasts.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_bluesky.xml b/feature/info/src/main/res/drawable/ic_link_bluesky.xml new file mode 100644 index 000000000..491476ca9 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_bluesky.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_buymeacoffee.xml b/feature/info/src/main/res/drawable/ic_link_buymeacoffee.xml new file mode 100644 index 000000000..eb8083011 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_buymeacoffee.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_discord.xml b/feature/info/src/main/res/drawable/ic_link_discord.xml new file mode 100644 index 000000000..a214505d5 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_discord.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_facebook.xml b/feature/info/src/main/res/drawable/ic_link_facebook.xml new file mode 100644 index 000000000..26a384f42 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_facebook.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_instagram.xml b/feature/info/src/main/res/drawable/ic_link_instagram.xml new file mode 100644 index 000000000..270c3406e --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_instagram.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_kofi.xml b/feature/info/src/main/res/drawable/ic_link_kofi.xml new file mode 100644 index 000000000..0c0b7cb33 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_kofi.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_linkedin.xml b/feature/info/src/main/res/drawable/ic_link_linkedin.xml new file mode 100644 index 000000000..ed39323cb --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_linkedin.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_patreon.xml b/feature/info/src/main/res/drawable/ic_link_patreon.xml new file mode 100644 index 000000000..d866c3be8 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_patreon.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_reddit.xml b/feature/info/src/main/res/drawable/ic_link_reddit.xml new file mode 100644 index 000000000..6b7f87f47 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_reddit.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_spotify.xml b/feature/info/src/main/res/drawable/ic_link_spotify.xml new file mode 100644 index 000000000..6f25ee052 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_spotify.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_threads.xml b/feature/info/src/main/res/drawable/ic_link_threads.xml new file mode 100644 index 000000000..47ed7e66f --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_threads.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_tiktok.xml b/feature/info/src/main/res/drawable/ic_link_tiktok.xml new file mode 100644 index 000000000..42e77c283 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_tiktok.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_twitch.xml b/feature/info/src/main/res/drawable/ic_link_twitch.xml new file mode 100644 index 000000000..36d5b87c5 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_twitch.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_x.xml b/feature/info/src/main/res/drawable/ic_link_x.xml new file mode 100644 index 000000000..ce9a618f3 --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_x.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/drawable/ic_link_youtube.xml b/feature/info/src/main/res/drawable/ic_link_youtube.xml new file mode 100644 index 000000000..94332568a --- /dev/null +++ b/feature/info/src/main/res/drawable/ic_link_youtube.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/info/src/main/res/values/strings.xml b/feature/info/src/main/res/values/strings.xml index 648e71831..58077850f 100644 --- a/feature/info/src/main/res/values/strings.xml +++ b/feature/info/src/main/res/values/strings.xml @@ -1,5 +1,75 @@ + Episode + Couldn’t load this episode + Back + Share episode + View %1$s + Show full title + Show less + Read more + About this episode + Episode links + Show all links + Show fewer links + Bonus + Trailer + Video + S%1$d · E%2$d + Season %1$d + Episode %1$d + Chapters + Show all chapters + Show fewer chapters + Jump to %1$s at %2$s + Current chapter + Play + Resume + Pause + Loading + %1$s played + %1$s left + Less than 1 min + Like + Liked + Unlike episode + Download + Downloaded + Downloading + Remove download + Queue + Queued + Add to queue + Remove from queue + Mark played + Played + Mark unplayed + Mark played to move this episode out of your unplayed list. + Featured show + Possible featured show + View show + This episode introduces %1$s. + This episode may feature %1$s. + Try these episodes + More from %1$s + Explore podcast + No other episodes available + People in this episode + Visit %1$s + Read %1$s + Watch %1$s + Watch on %1$s + View %1$s on %2$s + + %1$s + Open on %1$s + Support %1$s on %2$s + Listen to %1$s + Listen on %1$s + Visit %1$s on %2$s + Email %1$s + No app could open this link + %d chapter%d chapters Turn on episode notifications? Turning this on will make the feed link public. Keep notifications off for private or premium feeds. You can still listen, refresh and download episodes. Enable for public feed @@ -19,4 +89,5 @@ Subscribe to the show to edit genre Visible on this podcast page, Subscriptions filters, and folders. Click to set custom tag + %1$s, from %2$s diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIconTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIconTest.kt new file mode 100644 index 000000000..9c81f0103 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkBrandIconTest.kt @@ -0,0 +1,46 @@ +package cx.aswin.boxlore.feature.info + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class EpisodeLinkBrandIconTest { + @Test + fun `social profiles use their recognizable platform logo`() { + val expected = mapOf( + "www.instagram.com" to R.drawable.ic_link_instagram, + "m.facebook.com" to R.drawable.ic_link_facebook, + "fb.com" to R.drawable.ic_link_facebook, + "twitter.com" to R.drawable.ic_link_x, + "X.COM." to R.drawable.ic_link_x, + "threads.com" to R.drawable.ic_link_threads, + "threads.net" to R.drawable.ic_link_threads, + "linkedin.com" to R.drawable.ic_link_linkedin, + "bsky.app" to R.drawable.ic_link_bluesky, + "tiktok.com" to R.drawable.ic_link_tiktok, + "twitch.tv" to R.drawable.ic_link_twitch, + ) + expected.forEach { (host, resource) -> assertEquals(resource, episodeLinkBrandIcon(host), host) } + } + + @Test + fun `video community and support URLs keep brand identity across aliases`() { + assertEquals(R.drawable.ic_link_youtube, episodeLinkBrandIcon("youtu.be")) + assertEquals(R.drawable.ic_link_youtube, episodeLinkBrandIcon("music.youtube.com")) + assertEquals(R.drawable.ic_link_discord, episodeLinkBrandIcon("discord.gg")) + assertEquals(R.drawable.ic_link_discord, episodeLinkBrandIcon("discord.com")) + assertEquals(R.drawable.ic_link_reddit, episodeLinkBrandIcon("old.reddit.com")) + assertEquals(R.drawable.ic_link_spotify, episodeLinkBrandIcon("open.spotify.com")) + assertEquals(R.drawable.ic_link_applepodcasts, episodeLinkBrandIcon("podcasts.apple.com")) + assertEquals(R.drawable.ic_link_patreon, episodeLinkBrandIcon("patreon.com")) + assertEquals(R.drawable.ic_link_kofi, episodeLinkBrandIcon("ko-fi.com")) + assertEquals(R.drawable.ic_link_buymeacoffee, episodeLinkBrandIcon("buymeacoffee.com")) + } + + @Test + fun `lookalike sites email addresses and unknown hosts retain the generic purpose icon`() { + listOf("notinstagram.com", "instagram.com.example.org", "youtube.com.evil.test", "examplex.com", "hello@x.com", "radiolab.org", "apple.com", "").forEach { + assertNull(episodeLinkBrandIcon(it), it) + } + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkLabelTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkLabelTest.kt new file mode 100644 index 000000000..74e854390 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkLabelTest.kt @@ -0,0 +1,103 @@ +package cx.aswin.boxlore.feature.info + +import cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class EpisodeLinkLabelTest { + @Test + fun `generic platform titles never repeat platform as the profile name`() { + listOf("Instagram" to "Instagram", "Facebook" to "Visit Facebook", "Threads" to "Threads", "X" to "Twitter").forEach { (platform, title) -> + val link = EpisodeLink("https://example.org", "example.org", EpisodeLinkKind.SOCIAL, title = title, platform = platform) + val rootLabel = episodeLinkLabelSpec(link, "Show") + assertEquals(R.string.episode_info_link_open_on, rootLabel.resource) + assertEquals(listOf(platform), rootLabel.arguments) + assertNull(episodeLinkActionTitle(link)) + val profileLabel = episodeLinkLabelSpec(link.copy(handle = "@radiolab"), "Show") + assertEquals(R.string.episode_info_link_view_on, profileLabel.resource) + assertEquals(listOf("@radiolab", platform), profileLabel.arguments) + } + } + + @Test + fun `specific publisher action labels remain intact`() { + val link = EpisodeLink("https://instagram.com/p/episode", "instagram.com", EpisodeLinkKind.SOCIAL, title = "View Radiolab behind the scenes", platform = "Instagram") + assertEquals("View Radiolab behind the scenes", episodeLinkActionTitle(link)) + } + + @Test + fun `unlabelled websites name the actual destination rather than website`() { + val label = episodeLinkLabelSpec(EpisodeLink("https://radiolab.org", "radiolab.org", EpisodeLinkKind.WEBSITE), "Radiolab") + assertEquals(R.string.episode_info_link_visit, label.resource) + assertEquals(listOf("radiolab.org"), label.arguments) + } + + @Test + fun `article labels retain publisher context and recognised profiles show only their username`() { + val article = episodeLinkLabelSpec(EpisodeLink("https://example.org/news/research", "example.org", EpisodeLinkKind.ARTICLE, title = "The original research"), "Show") + assertEquals(R.string.episode_info_link_read, article.resource) + assertEquals(listOf("The original research"), article.arguments) + val social = episodeLinkLabelSpec(EpisodeLink("https://instagram.com/radiolab", "instagram.com", EpisodeLinkKind.SOCIAL, platform = "Instagram", handle = "@radiolab"), "Radiolab") + assertEquals(R.string.episode_info_link_profile, social.resource) + assertEquals(listOf("@radiolab"), social.arguments) + } + + @Test + fun `parsed social usernames replace publisher titles and action prefixes only with a recognised icon`() { + val profiles = listOf( + "https://instagram.com/radiolab", "https://facebook.com/radiolab", "https://threads.net/@radiolab", + "https://twitter.com/radiolab", "https://linkedin.com/company/radiolab", "https://tiktok.com/@radiolab", + "https://twitch.tv/radiolab", "https://youtube.com/@radiolab", "https://youtube.com/c/radiolab", + ) + profiles.forEach { url -> + val link = ShowNotesParser.parse("View Radiolab behind the scenes").links.single() + val label = episodeLinkLabelSpec(link, "Radiolab") + assertEquals(R.string.episode_info_link_profile, label.resource, url) + assertEquals(listOf("@radiolab"), label.arguments, url) + assertNull(episodeLinkActionTitle(link), url) + } + val bluesky = ShowNotesParser.parse("https://bsky.app/profile/radiolab.org").links.single() + assertEquals(listOf("@radiolab.org"), episodeLinkLabelSpec(bluesky, "Radiolab").arguments) + } + + @Test + fun `missing usernames and nonprofile routes retain descriptive labels`() { + listOf("https://instagram.com/", "https://instagram.com/accounts/login", "https://instagram.com/p/123", "https://facebook.com/profile.php?id=123", "https://youtube.com/channel/UC123").forEach { url -> + val link = ShowNotesParser.parse(url).links.single() + assertNull(episodeLinkProfileHandle(link), url) + assertEquals(R.string.episode_info_link_open_on, episodeLinkLabelSpec(link, "Show").resource, url) + } + val blank = EpisodeLink("https://instagram.com/", "instagram.com", EpisodeLinkKind.SOCIAL, platform = "Instagram", handle = " ") + assertNull(episodeLinkProfileHandle(blank)) + assertEquals(R.string.episode_info_link_open_on, episodeLinkLabelSpec(blank, "Show").resource) + } + + @Test + fun `unknown platforms and lookalike domains cannot shorten to just a claimed username`() { + listOf("example.org", "instagram.com.evil.org").forEach { host -> + val link = EpisodeLink("https://$host/show", host, EpisodeLinkKind.SOCIAL, platform = "Instagram", handle = "@show") + val label = episodeLinkLabelSpec(link, "Show") + assertNull(episodeLinkProfileHandle(link)) + assertEquals(R.string.episode_info_link_view_on, label.resource) + assertEquals(listOf("@show", "Instagram"), label.arguments) + } + } + + @Test + fun `video podcast support and email actions describe their destination`() { + val video = episodeLinkLabelSpec(EpisodeLink("https://youtube.com/watch?v=1", "youtube.com", EpisodeLinkKind.VIDEO, platform = "YouTube"), "Show") + assertEquals(listOf("YouTube"), video.arguments) + assertEquals(R.string.episode_info_link_watch_on, video.resource) + val podcast = episodeLinkLabelSpec(EpisodeLink("https://open.spotify.com/show/1", "open.spotify.com", EpisodeLinkKind.PODCAST, platform = "Spotify", title = "Another Show"), "Host") + assertEquals(listOf("Another Show"), podcast.arguments) + assertEquals(R.string.episode_info_link_listen, podcast.resource) + val support = episodeLinkLabelSpec(EpisodeLink("https://patreon.com/radiolab", "patreon.com", EpisodeLinkKind.SUPPORT, platform = "Patreon", handle = "radiolab"), "Radiolab") + assertEquals(listOf("radiolab", "Patreon"), support.arguments) + val email = episodeLinkLabelSpec(EpisodeLink("mailto:hello@radiolab.org", "hello@radiolab.org", EpisodeLinkKind.EMAIL), "Radiolab") + assertEquals(R.string.episode_info_link_email, email.resource) + assertEquals(listOf("hello@radiolab.org"), email.arguments) + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkPaletteTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkPaletteTest.kt new file mode 100644 index 000000000..4ef0372eb --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkPaletteTest.kt @@ -0,0 +1,58 @@ +package cx.aswin.boxlore.feature.info + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import kotlin.math.max +import kotlin.math.min +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class EpisodeLinkPaletteTest { + @Test + fun `known logos remain readable on solid tinted bubbles in light and dark themes`() { + val themes = listOf(Color(0xFFF3F1F6) to Color(0xFF1B1A20), Color(0xFF222027) to Color(0xFFE8E1E8)) + val hosts = listOf("instagram.com", "x.com", "threads.com", "facebook.com", "linkedin.com", "bsky.app", "youtube.com", "tiktok.com", "twitch.tv", "reddit.com", "discord.gg", "open.spotify.com", "podcasts.apple.com", "patreon.com", "ko-fi.com", "buymeacoffee.com") + themes.forEach { (surface, foreground) -> + hosts.forEach { host -> + val palette = episodeLinkPalette(episodeLinkBrandIcon(host)!!, surface, foreground) + assertEquals(1f, palette.container.alpha, host) + assertTrue(contrast(palette.icon, palette.container) >= 3f, "Logo contrast: $host") + assertTrue(contrast(foreground, palette.container) >= 4.5f, "Text contrast: $host") + } + } + } + + @Test + fun `TikTok uses a cyan accent rather than the monochrome fallback in both themes`() { + val themes = listOf(Color(0xFFF3F1F6) to Color(0xFF1B1A20), Color(0xFF222027) to Color(0xFFE8E1E8)) + themes.forEach { (surface, foreground) -> + val palette = episodeLinkPalette(R.drawable.ic_link_tiktok, surface, foreground) + val neutral = episodeLinkPalette(R.drawable.ic_link_x, surface, foreground) + assertNotEquals(neutral.container, palette.container) + assertTrue(palette.icon.green - palette.icon.red > 0.2f, "TikTok icon keeps its cyan hue") + assertTrue(palette.icon.blue - palette.icon.red > 0.2f, "TikTok icon keeps its cyan hue") + assertEquals(1f, palette.container.alpha) + assertTrue(contrast(palette.icon, palette.container) >= 3f) + assertTrue(contrast(foreground, palette.container) >= 4.5f) + } + } + + @Test + fun `X and Threads retain readable monochrome logos in both themes`() { + val themes = listOf(Color(0xFFF3F1F6) to Color(0xFF1B1A20), Color(0xFF222027) to Color(0xFFE8E1E8)) + themes.forEach { (surface, foreground) -> + listOf(R.drawable.ic_link_x, R.drawable.ic_link_threads).forEach { icon -> + val palette = episodeLinkPalette(icon, surface, foreground) + assertEquals(foreground, palette.icon) + } + } + } + + private fun contrast(first: Color, second: Color): Float { + val a = first.luminance() + val b = second.luminance() + return (max(a, b) + 0.05f) / (min(a, b) + 0.05f) + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkRowsTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkRowsTest.kt new file mode 100644 index 000000000..f8f10f2ab --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkRowsTest.kt @@ -0,0 +1,58 @@ +package cx.aswin.boxlore.feature.info + +import cx.aswin.boxlore.core.model.EpisodeLink +import cx.aswin.boxlore.core.model.EpisodeLinkKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EpisodeLinkRowsTest { + @Test + fun `no links reserve no rows and one link uses only one row`() { + val website = link("example.org") + assertEquals(emptyList>(), episodeLinkRows(emptyList())) + assertEquals(listOf(listOf(website)), episodeLinkRows(listOf(website))) + } + + @Test + fun `two links stack with the recognised link above the generic link`() { + val website = link("example.org") + val youtube = link("youtube.com") + assertEquals(listOf(listOf(youtube), listOf(website)), episodeLinkRows(listOf(website, youtube))) + } + + @Test + fun `generic links alternate rows in publisher order without dropping the odd item`() { + val links = listOf("example.org", "example.net", "example.com", "support.example.org", "news.example.org").map { link(it) } + assertEquals(listOf(listOf(links[0], links[2], links[4]), listOf(links[1], links[3])), episodeLinkRows(links)) + } + + @Test + fun `branded links lead in top bottom priority while both groups retain publisher order`() { + val website = link("example.org") + val instagram = link("instagram.com") + val article = link("news.example.org", EpisodeLinkKind.ARTICLE) + val youtube = link("youtube.com", EpisodeLinkKind.VIDEO) + val discord = link("discord.gg", EpisodeLinkKind.COMMUNITY) + val email = EpisodeLink("mailto:hello@example.org", "hello@example.org", EpisodeLinkKind.EMAIL) + val links = listOf(website, instagram, article, youtube, discord, email) + assertEquals(listOf(listOf(instagram, discord, article), listOf(youtube, website, email)), episodeLinkRows(links)) + assertEquals(listOf(website, instagram, article, youtube, discord, email), links) + } + + @Test + fun `known aliases subdomains and monochrome brands retain their relative order`() { + val links = listOf("M.INSTAGRAM.COM.", "twitter.com", "podcasts.apple.com", "open.spotify.com", "patreon.com").map { link(it) } + assertEquals(listOf(listOf(links[0], links[2], links[4]), listOf(links[1], links[3])), episodeLinkRows(links)) + } + + @Test + fun `lookalike hosts and email addresses cannot gain brand priority from their metadata`() { + val website = link("example.org") + val spoof = link("instagram.com.evil.org", EpisodeLinkKind.SOCIAL).copy(platform = "Instagram", handle = "@show") + val email = EpisodeLink("mailto:hello@x.com", "hello@x.com", EpisodeLinkKind.EMAIL) + val instagram = link("instagram.com", EpisodeLinkKind.SOCIAL) + assertEquals(listOf(listOf(instagram, spoof), listOf(website, email)), episodeLinkRows(listOf(website, spoof, email, instagram))) + } + + private fun link(host: String, kind: EpisodeLinkKind = EpisodeLinkKind.WEBSITE) = EpisodeLink("https://$host", host, kind) +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkVectorTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkVectorTest.kt new file mode 100644 index 000000000..b9d599be7 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/EpisodeLinkVectorTest.kt @@ -0,0 +1,20 @@ +package cx.aswin.boxlore.feature.info + +import androidx.compose.ui.graphics.vector.PathNode +import androidx.compose.ui.graphics.vector.PathParser +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EpisodeLinkVectorTest { + @Test + fun `Discord SVG arc flags retain the original curve in Android parsing`() { + val moduleDir = File("src/main/res/drawable").takeIf(File::isDirectory) + ?: File("feature/info/src/main/res/drawable") + val xml = moduleDir.resolve("ic_link_discord.xml").readText() + val path = Regex("android:pathData=\"([^\"]+)\"").find(xml)!!.groupValues[1] + val nodes = PathParser().parsePathString(path).toNodes() + val firstArc = nodes.filterIsInstance().first() + assertEquals(PathNode.RelativeArcTo(19.7913f, 19.7913f, 0f, false, false, -4.8851f, -1.5152f), firstArc) + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/EpisodeChapterTimeTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/EpisodeChapterTimeTest.kt new file mode 100644 index 000000000..31c64ded1 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/EpisodeChapterTimeTest.kt @@ -0,0 +1,14 @@ +package cx.aswin.boxlore.feature.info.components + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EpisodeChapterTimeTest { + @Test + fun `chapter timestamp rolls minutes into hours and ignores fractional seconds`() { + assertEquals("00:00", chapterTime(0.0)) + assertEquals("59:59", chapterTime(3599.9)) + assertEquals("1:00:00", chapterTime(3600.0)) + assertEquals("2:01:05", chapterTime(7265.0)) + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoaderTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoaderTest.kt new file mode 100644 index 000000000..508ae32a1 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/EpisodeInfoNotesLoaderTest.kt @@ -0,0 +1,85 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.model.Chapter +import cx.aswin.boxlore.core.model.ShowNotes +import cx.aswin.boxlore.core.testing.TestFixtures +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class EpisodeInfoNotesLoaderTest { + @Test + fun `description fallback publishes while remote chapters fail independently of promotion`() = runTest { + val published = mutableListOf>() + var promotionFinished = false + val loader = EpisodeInfoNotesLoader(this, { throw java.io.IOException("offline") }, { _, _, _, _ -> null }, StandardTestDispatcher(testScheduler)) + val episode = TestFixtures.episode(description = "00:00 Intro\n00:30 Interview").copy(chaptersUrl = "https://example.org/chapters.json") + loader.load(episode, "host", "Host", {}, published::add, { promotionFinished = true }) + advanceUntilIdle() + assertEquals(listOf(0.0, 30.0), published.single().map { it.startTime }) + assertTrue(promotionFinished) + } + + @Test + fun `remote chapter list replaces fallback after validation sorting and deduplication`() = runTest { + val published = mutableListOf>() + val loader = EpisodeInfoNotesLoader(this, { + listOf(Chapter(45.0, "Later"), Chapter(0.0, "Start"), Chapter(45.0, "Duplicate"), Chapter(-1.0, "Invalid"), Chapter(Double.NaN, "Invalid"), Chapter(100.0, "End")) + }, { _, _, _, _ -> null }, StandardTestDispatcher(testScheduler)) + loader.load(TestFixtures.episode(duration = 100, description = "00:00 Intro\n00:30 Interview").copy(chaptersUrl = "https://example.org/chapters"), "host", "Host", {}, published::add, {}) + advanceUntilIdle() + assertEquals(2, published.size) + assertEquals(listOf("Start", "Later"), published.last().map { it.title }) + } + + @Test + fun `late result from previous episode cannot overwrite current notes or chapters`() = runTest { + val oldRemote = CompletableDeferred>() + val publishedNotes = mutableListOf() + val publishedChapters = mutableListOf>() + val loader = EpisodeInfoNotesLoader(this, { withContext(NonCancellable) { oldRemote.await() } }, { _, _, _, _ -> null }, StandardTestDispatcher(testScheduler)) + loader.load(TestFixtures.episode(id = "old", description = "Old notes").copy(chaptersUrl = "https://example.org/old"), "host", "Host", publishedNotes::add, publishedChapters::add, {}) + runCurrent() + loader.load(TestFixtures.episode(id = "new", description = "New notes"), "host", "Host", publishedNotes::add, publishedChapters::add, {}) + runCurrent() + oldRemote.complete(listOf(Chapter(0.0, "Stale"))) + advanceUntilIdle() + assertEquals("New notes", publishedNotes.last().plainText) + assertTrue(publishedChapters.flatten().isEmpty()) + } + + @Test + fun `metadata refresh for same episode also invalidates old promotion publication`() = runTest { + val oldPromotion = CompletableDeferred() + val completed = mutableListOf() + val loader = EpisodeInfoNotesLoader(this, { emptyList() }, { episode, _, _, _ -> + if (episode.description == "Old") withContext(NonCancellable) { oldPromotion.await() } + null + }, StandardTestDispatcher(testScheduler)) + loader.load(TestFixtures.episode(id = "same", description = "Old"), "host", "Host", {}, {}, { completed.add("old") }) + runCurrent() + loader.load(TestFixtures.episode(id = "same", description = "New"), "host", "Host", {}, {}, { completed.add("new") }) + runCurrent() + oldPromotion.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("new"), completed) + } + + @Test + fun `explicit cancellation publishes no pending result`() = runTest { + var notesPublished = false + val loader = EpisodeInfoNotesLoader(this, { emptyList() }, { _, _, _, _ -> null }, StandardTestDispatcher(testScheduler)) + loader.load(TestFixtures.episode(), "host", "Host", { notesPublished = true }, {}, {}) + loader.cancel() + advanceUntilIdle() + assertEquals(false, notesPublished) + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelectionTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelectionTest.kt new file mode 100644 index 000000000..a4e82f8c8 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/MoreFromEpisodeSelectionTest.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.testing.TestFixtures +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MoreFromEpisodeSelectionTest { + @Test + fun `latest five exclude the current episode before applying the limit`() { + val episodes = (1..8).map { TestFixtures.episode(id = "$it", publishedDate = it.toLong()) }.reversed() + + assertEquals(listOf("7", "6", "5", "4", "3"), selectMoreFromEpisodes(episodes, "8").map { it.id }) + } + + @Test + fun `unordered results are presented newest first`() { + val episodes = listOf( + TestFixtures.episode(id = "oldest", publishedDate = 100), + TestFixtures.episode(id = "newest", publishedDate = 300), + TestFixtures.episode(id = "middle", publishedDate = 200), + ) + + assertEquals(listOf("newest", "middle", "oldest"), selectMoreFromEpisodes(episodes, "current").map { it.id }) + } + + @Test + fun `duplicate IDs do not consume slots and retain the newest copy`() { + val episodes = listOf( + TestFixtures.episode(id = "repeat", title = "Stale", publishedDate = 1), + TestFixtures.episode(id = "repeat", title = "Latest", publishedDate = 9), + ) + (2..7).map { TestFixtures.episode(id = "$it", publishedDate = it.toLong()) } + + val result = selectMoreFromEpisodes(episodes, "current") + + assertEquals(listOf("repeat", "7", "6", "5", "4"), result.map { it.id }) + assertEquals("Latest", result.first().title) + } + + @Test + fun `equal dates preserve repository order`() { + val episodes = listOf("third", "first", "second").map { TestFixtures.episode(id = it, publishedDate = 100) } + + assertEquals(episodes, selectMoreFromEpisodes(episodes, "current")) + } + + @Test + fun `short and empty catalogs do not repeat or fabricate episodes`() { + val current = TestFixtures.episode(id = "current", publishedDate = 300) + val other = TestFixtures.episode(id = "other", publishedDate = 200) + + assertEquals(listOf(other), selectMoreFromEpisodes(listOf(current, other), "current")) + assertTrue(selectMoreFromEpisodes(listOf(current), "current").isEmpty()) + assertTrue(selectMoreFromEpisodes(emptyList(), "current").isEmpty()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ad02fd57..888af9cd4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ material = "1.12.0" posthog = "3.45.0" work = "2.9.0" rssParser = "6.1.7" +jsoup = "1.23.2" lottie = "6.6.2" @@ -42,6 +43,7 @@ credentials = "1.3.0" googleid = "1.1.1" [libraries] +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } posthog-android = { group = "com.posthog", name = "posthog-android", version.ref = "posthog" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" }