diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
index 874585de1..12921c076 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
@@ -14,6 +14,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -22,6 +24,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
+import com.github.damontecres.wholphin.util.stripMarkdown
/**
* Show the overview text for an item. Uses a fixed size and allows for clicking.
@@ -59,7 +62,7 @@ fun OverviewText(
},
) {
Text(
- text = overview,
+ text = overview.stripMarkdown(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index 8e7c89b14..c6d7057b2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@@ -27,6 +29,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
import com.github.damontecres.wholphin.util.languageName
+import com.mikepenz.markdown.m3.Markdown
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
@@ -88,10 +91,7 @@ fun ItemDetailsDialog(
)
}
if (info.overview.isNotNullOrBlank()) {
- Text(
- text = info.overview,
- style = MaterialTheme.typography.bodyMedium,
- )
+ Markdown(content = info.overview)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index ef0fdbadd..feeecd9ea 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -85,6 +86,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.stripMarkdown
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
@@ -526,7 +528,7 @@ fun HomePageHeader(
.width(400.dp)
if (overview.isNotNullOrBlank()) {
Text(
- text = overview,
+ text = overview.stripMarkdown(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (overviewTwoLines) 2 else 3,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt
new file mode 100644
index 000000000..f5140908b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/TextUtils.kt
@@ -0,0 +1,52 @@
+package com.github.damontecres.wholphin.util
+
+private val MARKDOWN_CHARS = charArrayOf(
+ '#', '*', '_', '~', '`', '[', '!', '>', '<', '-', '+', '.', '(', ')',
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
+)
+
+/**
+ * Strips common Markdown and HTML tags from a string for plain-text display.
+ */
+fun String.stripMarkdown(): String {
+ // Early return if no markdown-like characters are present and no trimming is needed.
+ if (this.none { it in MARKDOWN_CHARS } && this.trim() == this) return this
+
+ return this
+ // Code blocks (``` ... ```) - Remove first to avoid matching contents
+ .replace(Regex("(?s)```.*?```"), "")
+ // Horizontal rules (--- or ***) - MUST be before bold/italic
+ .replace(Regex("(?m)^([-*_]){3,}\\s*$"), "")
+ // Headers (# Heading)
+ .replace(Regex("(?m)^#{1,6}\\s+"), "")
+ // Bold + italic (***text*** or ___text___)
+ .replace(Regex("\\*{3}(.+?)\\*{3}"), "$1")
+ .replace(Regex("_{3}(.+?)_{3}"), "$1")
+ // Bold (**text** or __text__)
+ .replace(Regex("\\*{2}(.+?)\\*{2}"), "$1")
+ .replace(Regex("_{2}(.+?)_{2}"), "$1")
+ // Italic (*text* or _text_)
+ .replace(Regex("\\*(.+?)\\*"), "$1")
+ .replace(Regex("_(.+?)_"), "$1")
+ // Strikethrough (~~text~~)
+ .replace(Regex("~~(.+?)~~"), "$1")
+ // Inline code (`code`)
+ .replace(Regex("`(.+?)`"), "$1")
+ // Images ()
+ .replace(Regex("!\\[.*?]\\(.*?\\)"), "")
+ // Links ([text](url)) → keep text
+ .replace(Regex("\\[(.+?)]\\(.*?\\)"), "$1")
+ // Blockquotes (> text)
+ .replace(Regex("(?m)^>\\s+"), "")
+ // Unordered lists (- item, * item, + item)
+ .replace(Regex("(?m)^[\\-*+]\\s+"), "")
+ // Ordered lists (1. item)
+ .replace(Regex("(?m)^\\d+\\.\\s+"), "")
+ // HTML tags
+ .replace(Regex("<[^>]+>"), "")
+ // Clean up extra blank lines (more than 2)
+ .replace(Regex("(\\r?\\n){3,}"), "\n\n")
+ // Collapse multiple spaces to a single space
+ .replace(Regex(" {2,}"), " ")
+ .trim()
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt
new file mode 100644
index 000000000..75a113fbc
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/util/TextUtilsKtTest.kt
@@ -0,0 +1,163 @@
+package com.github.damontecres.wholphin.util
+
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertSame
+import junit.framework.TestCase.assertTrue
+import org.junit.Test
+
+class TextUtilsKtTest {
+ @Test
+ fun `Empty string input`() {
+ assertEquals("", "".stripMarkdown())
+ }
+
+ @Test
+ fun `String without markdown characters`() {
+ val plain = "Hello, world! This is plain text."
+ val result = plain.stripMarkdown()
+ assertSame(plain, result) // fast path must return the exact same reference
+ }
+
+ @Test
+ fun `Headers level 1 to 6 removal`() {
+ assertEquals("Heading", "# Heading".stripMarkdown())
+ assertEquals("Heading", "## Heading".stripMarkdown())
+ assertEquals("Heading", "### Heading".stripMarkdown())
+ assertEquals("Heading", "#### Heading".stripMarkdown())
+ assertEquals("Heading", "##### Heading".stripMarkdown())
+ assertEquals("Heading", "###### Heading".stripMarkdown())
+ }
+
+ @Test
+ fun `Triple emphasis Bold and Italic stripping`() {
+ assertEquals("text", "***text***".stripMarkdown())
+ assertEquals("text", "___text___".stripMarkdown())
+ }
+
+ @Test
+ fun `Double emphasis Bold stripping`() {
+ assertEquals("text", "**text**".stripMarkdown())
+ assertEquals("text", "__text__".stripMarkdown())
+ }
+
+ @Test
+ fun `Single emphasis Italic stripping`() {
+ assertEquals("text", "*text*".stripMarkdown())
+ assertEquals("text", "_text_".stripMarkdown())
+ }
+
+ @Test
+ fun `Strikethrough stripping`() {
+ assertEquals("text", "~~text~~".stripMarkdown())
+ }
+
+ @Test
+ fun `Inline code stripping`() {
+ assertEquals("code", "`code`".stripMarkdown())
+ assertEquals("Use code here", "Use `code` here".stripMarkdown())
+ }
+
+ @Test
+ fun `Multi line code block removal`() {
+ val input =
+ """
+ Before
+ ```
+ fun foo() = 42
+ ```
+ After
+ """.trimIndent()
+ val result = input.stripMarkdown()
+ assertFalse(result.contains("fun foo"))
+ assertTrue(result.contains("Before"))
+ assertTrue(result.contains("After"))
+ }
+
+ @Test
+ fun `Markdown link conversion`() {
+ assertEquals("Click here", "[Click here](https://example.com)".stripMarkdown())
+ assertFalse("[text](https://example.com)".stripMarkdown().contains("https"))
+ }
+
+ @Test
+ fun `Blockquote prefix removal`() {
+ assertEquals("A quote", "> A quote".stripMarkdown())
+ assertEquals("Line one\nLine two", "> Line one\n> Line two".stripMarkdown())
+ }
+
+ @Test
+ fun `Unordered list marker removal`() {
+ assertEquals("Item", "- Item".stripMarkdown())
+ assertEquals("Item", "* Item".stripMarkdown())
+ assertEquals("Item", "+ Item".stripMarkdown())
+ }
+
+ @Test
+ fun `Ordered list marker removal`() {
+ assertEquals("First", "1. First".stripMarkdown())
+ assertEquals("Second", "2. Second".stripMarkdown())
+ assertEquals("Tenth", "10. Tenth".stripMarkdown())
+ }
+
+ @Test
+ fun `Horizontal rule removal`() {
+ assertEquals("", "---".stripMarkdown())
+ assertEquals("", "***".stripMarkdown())
+ assertEquals("", "___".stripMarkdown())
+ assertEquals("", "------".stripMarkdown())
+ }
+
+ @Test
+ fun `HTML tag stripping`() {
+ assertEquals("text", "text".stripMarkdown())
+ assertEquals("Hello world", "Hello
world".stripMarkdown())
+ }
+
+ @Test
+ fun `Nested emphasis handling`() {
+ // ***text*** should collapse to just "text"
+ assertEquals("text", "***text***".stripMarkdown())
+ // **bold *italic*** — inner markers stripped, text preserved
+ val result = "**bold *italic***".stripMarkdown()
+ assertTrue(result.contains("bold"))
+ assertTrue(result.contains("italic"))
+ }
+
+ @Test
+ fun `Escaped markdown character behavior`() {
+ // Current impl does NOT handle escapes — document the actual behaviour
+ val result = """\*not italic\*""".stripMarkdown()
+ // Backslashes are left intact; no italic stripping occurs on escaped markers
+ assertTrue(result.contains("not italic"))
+ }
+
+ @Test
+ fun `Non matching markdown characters`() {
+ // '#' mid-word must not be stripped (regex is anchored to line start)
+ assertEquals("colour #FF0000", "colour #FF0000".stripMarkdown())
+ // '>' inside a sentence must not be stripped
+ assertEquals("a > b", "a > b".stripMarkdown())
+ }
+
+ @Test
+ fun `Incomplete markdown syntax`() {
+ // Unmatched markers — no crash, text is preserved as-is
+ val result = "**bold without close".stripMarkdown()
+ assertTrue(result.contains("bold without close"))
+
+ val result2 = "[link(url)".stripMarkdown()
+ assertTrue(result2.isNotEmpty())
+ }
+
+ @Test
+ fun `Large text performance`() {
+ val chunk = "# Title\n\n**bold** and *italic* with [link](url)\n\n"
+ val large = chunk.repeat(10_000)
+ val start = System.currentTimeMillis()
+ val result = large.stripMarkdown()
+ val elapsed = System.currentTimeMillis() - start
+ assertTrue(result.isNotEmpty())
+ assertTrue("Took ${elapsed}ms, expected < 2000ms", elapsed < 2000)
+ }
+}