diff --git a/.github/workflows/MediaInfo-Android_Checks.yml b/.github/workflows/MediaInfo-Android_Checks.yml
index b1c83b3222..c60c7e6d7a 100644
--- a/.github/workflows/MediaInfo-Android_Checks.yml
+++ b/.github/workflows/MediaInfo-Android_Checks.yml
@@ -38,6 +38,13 @@ jobs:
uses: actions/checkout@v6
with:
path: MediaInfo
+ - name: Run static analysis
+ run: |
+ export JAVA_HOME="$JAVA_HOME_${{ env.JAVA_VER }}_X64"
+ pushd ${{ github.workspace }}/MediaInfo/Source/GUI/Android
+ chmod +x gradlew
+ ./gradlew detektDebug
+ popd
- name: Build APKs
run: |
export JAVA_HOME="$JAVA_HOME_${{ env.JAVA_VER }}_X64"
@@ -58,3 +65,4 @@ jobs:
curl "https://android.googlesource.com/platform/system/extras/+/main/tools/check_elf_alignment.sh?format=TEXT"| base64 --decode > check_elf_alignment.sh
chmod +x check_elf_alignment.sh
./check_elf_alignment.sh ${{ github.workspace }}/MediaInfo/Source/GUI/Android/app/build/outputs/apk/release/app-universal-release-unsigned.apk
+ ./check_elf_alignment.sh ${{ github.workspace }}/MediaInfo/Source/GUI/Android/tvapp/build/outputs/apk/release/tvapp-universal-release-unsigned.apk
diff --git a/Source/GUI/Android/app/build.gradle b/Source/GUI/Android/app/build.gradle
index ec18b24c0a..94027c8378 100644
--- a/Source/GUI/Android/app/build.gradle
+++ b/Source/GUI/Android/app/build.gradle
@@ -16,42 +16,13 @@ android {
applicationId "net.mediaarea.mediainfo"
multiDexEnabled = true
minSdkVersion 21
- versionCode 57
- versionName "26.01"
+ versionCode 56
+ versionName "25.10"
targetSdkVersion 36
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
- externalNativeBuild {
- cmake {
- arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
- "-DENABLE_UNICODE=ON",
- "-DMEDIAINFO_ADVANCED=OFF",
- "-DMEDIAINFO_AES=OFF",
- "-DMEDIAINFO_DEMUX=OFF",
- "-DMEDIAINFO_DIRECTORY=OFF",
- "-DMEDIAINFO_DUPLICATE=OFF",
- "-DMEDIAINFO_DVDIF_ANALYZE=OFF",
- "-DMEDIAINFO_EVENTS=OFF",
- "-DMEDIAINFO_FILTER=OFF",
- "-DMEDIAINFO_FIXITY=OFF",
- "-DMEDIAINFO_GRAPH=OFF",
- "-DMEDIAINFO_IBI=OFF",
- "-DMEDIAINFO_LIBCURL=OFF",
- "-DMEDIAINFO_LIBMMS=OFF",
- "-DMEDIAINFO_MACROBLOCKS=OFF",
- "-DMEDIAINFO_MD5=OFF",
- "-DMEDIAINFO_NEXTPACKET=OFF",
- "-DMEDIAINFO_READER=OFF",
- "-DMEDIAINFO_READTHREAD=OFF",
- "-DMEDIAINFO_REFERENCES=OFF",
- "-DMEDIAINFO_SHA1=OFF",
- "-DMEDIAINFO_SHA2=OFF",
- "-DMEDIAINFO_TRACE=OFF",
- "-DMEDIAINFO_TRACE_FFV1CONTENT=OFF"
- }
- }
splits {
abi {
reset()
@@ -79,11 +50,6 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
- externalNativeBuild {
- cmake {
- path "CMakeLists.txt"
- }
- }
splits {
abi {
def isBuildingBundle = gradle.startParameter.taskNames.any { it.toLowerCase().contains('bundle') }
@@ -202,7 +168,7 @@ tasks.register('copyLocales', DefaultTask) {
preBuild.dependsOn copyLocales
dependencies {
- implementation fileTree(include: ['*.jar'], dir: 'libs')
+ implementation project(':mediainfolib')
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.google.android.material:material:1.13.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
diff --git a/Source/GUI/Android/app/proguard-rules.pro b/Source/GUI/Android/app/proguard-rules.pro
index 399cfeeba4..56a7309cf0 100644
--- a/Source/GUI/Android/app/proguard-rules.pro
+++ b/Source/GUI/Android/app/proguard-rules.pro
@@ -14,9 +14,4 @@
# Uncomment this to preserve the line number information for
# debugging stack traces.
--keepattributes SourceFile,LineNumberTable
-
-# MediaInfo
--keepclassmembers class net.mediaarea.mediainfo.MediaInfo {
- *;
-}
\ No newline at end of file
+-keepattributes SourceFile,LineNumberTable
\ No newline at end of file
diff --git a/Source/GUI/Android/build.gradle b/Source/GUI/Android/build.gradle
index b6034bb408..6bc106f281 100644
--- a/Source/GUI/Android/build.gradle
+++ b/Source/GUI/Android/build.gradle
@@ -19,6 +19,7 @@ buildscript {
plugins {
id 'com.google.devtools.ksp' version "$kotlin_version-2.0.4" apply false
+ id 'org.jetbrains.kotlin.plugin.compose' version "$kotlin_version" apply false
}
allprojects {
diff --git a/Source/GUI/Android/mediainfolib/.gitignore b/Source/GUI/Android/mediainfolib/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/Source/GUI/Android/mediainfolib/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/Source/GUI/Android/app/CMakeLists.txt b/Source/GUI/Android/mediainfolib/CMakeLists.txt
similarity index 100%
rename from Source/GUI/Android/app/CMakeLists.txt
rename to Source/GUI/Android/mediainfolib/CMakeLists.txt
diff --git a/Source/GUI/Android/mediainfolib/build.gradle.kts b/Source/GUI/Android/mediainfolib/build.gradle.kts
new file mode 100644
index 0000000000..f6930a0a6a
--- /dev/null
+++ b/Source/GUI/Android/mediainfolib/build.gradle.kts
@@ -0,0 +1,80 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "net.mediaarea.mediainfo.lib"
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ minSdk = 21
+
+ @Suppress("UnstableApiUsage")
+ externalNativeBuild {
+ cmake {
+ arguments(
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
+ "-DENABLE_UNICODE=ON",
+ "-DMEDIAINFO_ADVANCED=OFF",
+ "-DMEDIAINFO_AES=OFF",
+ "-DMEDIAINFO_DEMUX=OFF",
+ "-DMEDIAINFO_DIRECTORY=OFF",
+ "-DMEDIAINFO_DUPLICATE=OFF",
+ "-DMEDIAINFO_DVDIF_ANALYZE=OFF",
+ "-DMEDIAINFO_EVENTS=OFF",
+ "-DMEDIAINFO_FILTER=OFF",
+ "-DMEDIAINFO_FIXITY=OFF",
+ "-DMEDIAINFO_GRAPH=OFF",
+ "-DMEDIAINFO_IBI=OFF",
+ "-DMEDIAINFO_LIBCURL=OFF",
+ "-DMEDIAINFO_LIBMMS=OFF",
+ "-DMEDIAINFO_MACROBLOCKS=OFF",
+ "-DMEDIAINFO_MD5=OFF",
+ "-DMEDIAINFO_NEXTPACKET=OFF",
+ "-DMEDIAINFO_READER=OFF",
+ "-DMEDIAINFO_READTHREAD=OFF",
+ "-DMEDIAINFO_REFERENCES=OFF",
+ "-DMEDIAINFO_SHA1=OFF",
+ "-DMEDIAINFO_SHA2=OFF",
+ "-DMEDIAINFO_TRACE=OFF",
+ "-DMEDIAINFO_TRACE_FFV1CONTENT=OFF"
+ )
+ }
+ }
+
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ externalNativeBuild {
+ cmake {
+ path = file("CMakeLists.txt")
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_21
+ }
+ }
+}
+
+dependencies {
+
+}
diff --git a/Source/GUI/Android/mediainfolib/consumer-rules.pro b/Source/GUI/Android/mediainfolib/consumer-rules.pro
new file mode 100644
index 0000000000..27cb7647be
--- /dev/null
+++ b/Source/GUI/Android/mediainfolib/consumer-rules.pro
@@ -0,0 +1,3 @@
+-keepclassmembers class net.mediaarea.mediainfo.MediaInfo {
+ *;
+}
\ No newline at end of file
diff --git a/Source/GUI/Android/mediainfolib/proguard-rules.pro b/Source/GUI/Android/mediainfolib/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/Source/GUI/Android/mediainfolib/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/Source/GUI/Android/mediainfolib/src/main/AndroidManifest.xml b/Source/GUI/Android/mediainfolib/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..a5918e68ab
--- /dev/null
+++ b/Source/GUI/Android/mediainfolib/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/MediaInfo.kt b/Source/GUI/Android/mediainfolib/src/main/java/net/mediaarea/mediainfo/MediaInfo.kt
similarity index 100%
rename from Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/MediaInfo.kt
rename to Source/GUI/Android/mediainfolib/src/main/java/net/mediaarea/mediainfo/MediaInfo.kt
diff --git a/Source/GUI/Android/settings.gradle b/Source/GUI/Android/settings.gradle
index e7b4def49c..c3a0eb7176 100644
--- a/Source/GUI/Android/settings.gradle
+++ b/Source/GUI/Android/settings.gradle
@@ -1 +1,3 @@
include ':app'
+include ':tvapp'
+include ':mediainfolib'
diff --git a/Source/GUI/Android/tvapp/.gitignore b/Source/GUI/Android/tvapp/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/Source/GUI/Android/tvapp/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/build.gradle.kts b/Source/GUI/Android/tvapp/build.gradle.kts
new file mode 100644
index 0000000000..dd228c3d3f
--- /dev/null
+++ b/Source/GUI/Android/tvapp/build.gradle.kts
@@ -0,0 +1,107 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("dev.detekt") version "latest.release"
+}
+
+android {
+ namespace = "net.mediaarea.mediainfo.tv"
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ applicationId = "net.mediaarea.mediainfo.tv"
+ minSdk = 23
+ targetSdk = 36
+ versionCode = 1
+ versionName = "25.10"
+
+ splits {
+ abi {
+ reset()
+ include("x86", "x86_64", "armeabi-v7a", "arm64-v8a")
+ }
+ }
+
+ if (project.hasProperty("RELEASE_STORE_FILE")) {
+ signingConfigs {
+ create("release") {
+ storeFile = file(project.property("RELEASE_STORE_FILE") as String)
+ storePassword = project.property("RELEASE_STORE_PASSWORD") as String
+ keyAlias = project.property("RELEASE_KEY_ALIAS") as String
+ keyPassword = project.property("RELEASE_KEY_PASSWORD") as String
+ }
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ if (project.hasProperty("RELEASE_STORE_FILE")) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ val isBuildingBundle = gradle.startParameter.taskNames.any {
+ it.contains("bundle", ignoreCase = true)
+ }
+ splits {
+ abi {
+ isEnable = !isBuildingBundle
+ isUniversalApk = true
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_21
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+}
+
+detekt {
+ parallel = true
+ buildUponDefaultConfig = true
+ config.setFrom("detekt-config.yml")
+}
+
+dependencies {
+ implementation(project(":mediainfolib"))
+ implementation("androidx.core:core-ktx:1.18.0")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation(platform("androidx.compose:compose-bom:2026.03.00"))
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.datastore:datastore-preferences:1.2.1")
+ implementation("androidx.tv:tv-foundation:1.0.0-beta01")
+ implementation("androidx.tv:tv-material:1.0.1")
+ implementation("androidx.lifecycle:lifecycle-process:2.10.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
+ implementation("androidx.activity:activity-compose:1.13.0")
+ implementation("androidx.navigation:navigation-compose:2.9.7")
+ implementation("com.google.accompanist:accompanist-permissions:0.37.3")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2026.03.00"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/Source/GUI/Android/tvapp/detekt-config.yml b/Source/GUI/Android/tvapp/detekt-config.yml
new file mode 100644
index 0000000000..a916713a2c
--- /dev/null
+++ b/Source/GUI/Android/tvapp/detekt-config.yml
@@ -0,0 +1,19 @@
+naming:
+ FunctionNaming:
+ ignoreAnnotated: ['Composable']
+potential-bugs:
+ Deprecation:
+ active: true
+style:
+ MagicNumber:
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts', '**/ui/theme/Color.kt']
+ ignoreNumbers:
+ - '-1'
+ - '0'
+ - '1'
+ - '2'
+ - '0.6f'
+ UnusedImport:
+ active: true
+ UnusedPrivateFunction:
+ ignoreAnnotated: ['Preview']
diff --git a/Source/GUI/Android/tvapp/proguard-rules.pro b/Source/GUI/Android/tvapp/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/Source/GUI/Android/tvapp/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/AndroidManifest.xml b/Source/GUI/Android/tvapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..0e5e321aba
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/Core.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/Core.kt
new file mode 100644
index 0000000000..a5dc66ab12
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/Core.kt
@@ -0,0 +1,54 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv
+
+import net.mediaarea.mediainfo.MediaInfo
+import net.mediaarea.mediainfo.tv.feature.settings.DisplayCaptions
+
+object Core {
+ data class ReportView(
+ val name: String,
+ val desc: String,
+ val mime: String,
+ val exportable: Boolean
+ ) {
+ override fun toString(): String {
+ return desc
+ }
+ }
+
+ val mi: MediaInfo = MediaInfo()
+ val views: MutableList = mutableListOf()
+ val version: String = mi.Option("Info_Version").replace("MediaInfoLib - v", "")
+
+ init {
+ // populate views list
+ val viewsCsv: String = mi.Option("Info_OutputFormats_CSV")
+ viewsCsv.split("\n").forEach {
+ val view: List = it.split(",")
+ if (view.size > 2)
+ views.add(ReportView(view[0], view[1], view[2], true))
+ }
+ }
+
+ fun generateReport(
+ fd: Int,
+ name: String,
+ legacyStreamDisplay: Boolean,
+ displayCaptions: DisplayCaptions
+ ): String {
+ mi.Option("Inform", "HTML")
+ mi.Option("LegacyStreamDisplay", if (legacyStreamDisplay) "1" else "0")
+ mi.Option("File_DisplayCaptions", displayCaptions.key)
+
+ mi.Open(fd, name)
+ val report = mi.Inform()
+ mi.Close()
+
+ return report
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainActivity.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainActivity.kt
new file mode 100644
index 0000000000..0a6b549e28
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainActivity.kt
@@ -0,0 +1,178 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.annotation.StringRes
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.tv.material3.DrawerState
+import androidx.tv.material3.DrawerValue
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.NavigationDrawer
+import androidx.tv.material3.NavigationDrawerItem
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import androidx.tv.material3.rememberDrawerState
+import androidx.tv.material3.surfaceColorAtElevation
+import net.mediaarea.mediainfo.tv.feature.about.About
+import net.mediaarea.mediainfo.tv.feature.browse.Browse
+import net.mediaarea.mediainfo.tv.feature.settings.Settings
+import net.mediaarea.mediainfo.tv.ui.theme.AndroidTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MediaInfoAppContainer()
+ }
+ }
+}
+
+@Composable
+private fun MediaInfoAppContainer() {
+ val app = LocalContext.current.applicationContext as MediaInfoApplication
+ val viewModel: MainViewModel = viewModel(factory = app.factory)
+
+ // Collect the state from the ViewModel in a lifecycle-aware way
+ val isDark by viewModel.darkModeEnabled.collectAsStateWithLifecycle()
+
+ AndroidTheme(isInDarkTheme = isDark) {
+ MediaInfoApp()
+ }
+}
+
+@Composable
+private fun MediaInfoApp(drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Open)) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ val navController = rememberNavController()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route
+ val items =
+ listOf(
+ Screen.Browse,
+ Screen.Settings,
+ Screen.About
+ )
+
+ NavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = { drawerValue ->
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceColorAtElevation(0.dp))
+ .fillMaxHeight()
+ .padding(12.dp)
+ .selectableGroup(),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically)
+ ) {
+ items.forEach { screen ->
+ NavigationDrawerItem(
+ selected = currentRoute == screen.route,
+ onClick = {
+ navController.navigate(screen.route) {
+ // Pop up to start destination to avoid building up a large stack
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ leadingContent = {
+ Icon(imageVector = screen.icon, contentDescription = null)
+ }
+ ) {
+ Text(stringResource(screen.resourceId))
+ }
+ }
+ }
+ if (drawerValue == DrawerValue.Open) {
+ MediaInfoHeader(Modifier.align(Alignment.TopStart))
+ }
+ }
+ ) {
+ NavHost(navController, startDestination = Screen.Browse.route) {
+ composable(Screen.Browse.route) { Browse() }
+ composable(Screen.Settings.route) { Settings() }
+ composable(Screen.About.route) { About() }
+ }
+ }
+ }
+}
+
+private sealed class Screen(
+ val route: String,
+ @get:StringRes val resourceId: Int,
+ val icon: ImageVector
+) {
+ object Browse : Screen("browse", R.string.browse, Icons.Default.Folder)
+ object Settings : Screen("settings", R.string.settings, Icons.Default.Settings)
+ object About : Screen("about", R.string.about, Icons.Default.Info)
+}
+
+@Composable
+private fun MediaInfoHeader(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.padding(20.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaInfoAppPreview() {
+ AndroidTheme {
+ MediaInfoApp()
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaInfoAppPreviewDrawerClosed() {
+ AndroidTheme {
+ MediaInfoApp(rememberDrawerState(initialValue = DrawerValue.Closed))
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainViewModel.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainViewModel.kt
new file mode 100644
index 0000000000..0e89825634
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MainViewModel.kt
@@ -0,0 +1,28 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// From code generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import net.mediaarea.mediainfo.tv.feature.settings.FLOW_TIMEOUT
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsRepository
+
+class MainViewModel(repository: SettingsRepository) : ViewModel() {
+ val darkModeEnabled: StateFlow = repository.settingsFlow
+ .map { it.darkModeEnabled }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(FLOW_TIMEOUT),
+ initialValue = true
+ )
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MediaInfoApplication.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MediaInfoApplication.kt
new file mode 100644
index 0000000000..a37d9b81af
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/MediaInfoApplication.kt
@@ -0,0 +1,15 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv
+
+import android.app.Application
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsRepository
+
+class MediaInfoApplication : Application() {
+ val settingsRepository by lazy { SettingsRepository(applicationContext) }
+ val factory by lazy { ViewModelFactory(this, settingsRepository) }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ViewModelFactory.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ViewModelFactory.kt
new file mode 100644
index 0000000000..82ecebe0d8
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ViewModelFactory.kt
@@ -0,0 +1,47 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// From code generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import net.mediaarea.mediainfo.tv.feature.browse.MediaRepository
+import net.mediaarea.mediainfo.tv.feature.browse.MediaViewModel
+import net.mediaarea.mediainfo.tv.feature.report.ReportViewModel
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsRepository
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsViewModel
+
+class ViewModelFactory(
+ private val application: Application,
+ private val repository: SettingsRepository
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return when {
+
+ modelClass.isAssignableFrom(MainViewModel::class.java) -> {
+ MainViewModel(repository) as T
+ }
+
+ modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
+ SettingsViewModel(repository) as T
+ }
+
+ modelClass.isAssignableFrom(MediaViewModel::class.java) -> {
+ MediaViewModel(MediaRepository(application.applicationContext), repository) as T
+ }
+
+ modelClass.isAssignableFrom(ReportViewModel::class.java) -> {
+ ReportViewModel(application.contentResolver, repository) as T
+ }
+
+ else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/about/About.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/about/About.kt
new file mode 100644
index 0000000000..26f2064300
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/about/About.kt
@@ -0,0 +1,93 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.feature.about
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import net.mediaarea.mediainfo.tv.BuildConfig
+import net.mediaarea.mediainfo.tv.Core
+import net.mediaarea.mediainfo.tv.R
+import net.mediaarea.mediainfo.tv.ui.theme.AndroidTheme
+
+@Composable
+fun About() {
+ AboutContent(mediainfoLibVersion = Core.version)
+}
+
+@Composable
+private fun AboutContent(
+ mediainfoLibVersion: String
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(80.dp)
+ .focusable(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val modifier = Modifier.padding(10.dp)
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name) + " icon",
+ )
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = modifier,
+ )
+ Text(
+ text = stringResource(R.string.about_about_text)
+ .replace("%MI_VERSION%", BuildConfig.VERSION_NAME)
+ .replace("%MIL_VERSION%", mediainfoLibVersion),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier = modifier,
+ )
+ Text(
+ text = stringResource(R.string.about_description_text),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = modifier,
+ )
+ Text(
+ text = stringResource(R.string.about_copyright_text),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = modifier,
+ )
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun AboutPreview() {
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ AboutContent(BuildConfig.VERSION_NAME)
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/Browse.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/Browse.kt
new file mode 100644
index 0000000000..877ec3f099
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/Browse.kt
@@ -0,0 +1,266 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.feature.browse
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Movie
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.tv.material3.Button
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import net.mediaarea.mediainfo.tv.MediaInfoApplication
+import net.mediaarea.mediainfo.tv.R
+import net.mediaarea.mediainfo.tv.feature.report.ReportActivity
+import net.mediaarea.mediainfo.tv.ui.components.MediaListItem
+import net.mediaarea.mediainfo.tv.ui.theme.AndroidTheme
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun Browse() {
+ val permissionsState = rememberMultiplePermissionsState(
+ permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ listOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_AUDIO
+ )
+ } else {
+ listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ )
+
+ if (permissionsState.allPermissionsGranted) {
+ MediaBrowserScreen()
+ } else {
+ PermissionDeniedNotice { permissionsState.launchMultiplePermissionRequest() }
+ }
+}
+
+@Composable
+private fun MediaBrowserScreen() {
+ val app = LocalContext.current.applicationContext as MediaInfoApplication
+ val viewModel: MediaViewModel = viewModel(factory = app.factory)
+ val state by viewModel.uiState.collectAsState()
+
+ // Fetch MediaStore at least once or on app process resume
+ LifecycleResumeEffect(
+ key1 = Unit,
+ lifecycleOwner = ProcessLifecycleOwner.get()
+ ) {
+ viewModel.loadMedia()
+ onPauseOrDispose {}
+ }
+
+ MediaBrowserScreenContent(state)
+}
+
+@Composable
+private fun MediaBrowserScreenContent(state: MediaUiState) {
+ val context = LocalContext.current
+
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ when (state) {
+ is MediaUiState.Loading -> {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ CircularProgressIndicator()
+ Text(stringResource(R.string.loading))
+ }
+ }
+
+ is MediaUiState.Empty -> {
+ Text(stringResource(R.string.empty))
+ }
+
+ is MediaUiState.Error -> {
+ Text(stringResource(R.string.error) + ": " + state.message)
+ }
+
+ is MediaUiState.Success -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(state.items) { item ->
+ MediaListItem(
+ name = item.name,
+ icon = when (item.type) {
+ MediaType.Video -> Icons.Default.Movie
+ MediaType.Audio -> Icons.Default.MusicNote
+ MediaType.Image -> Icons.Default.Image
+ },
+ path = item.path,
+ date = item.dateModified,
+ onClick = {
+ val intent = Intent(context, ReportActivity::class.java).apply {
+ data = item.uri
+ putExtra("COMPLETE_NAME", item.path)
+ putExtra("FILE_NAME", item.name)
+ }
+ context.startActivity(intent)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionDeniedNotice(onRequestPermission: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.permission_header),
+ style = MaterialTheme.typography.headlineMedium,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.permission_text, stringResource(R.string.app_name)),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 48.dp)
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = onRequestPermission) {
+ Text(stringResource(R.string.permission_button))
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun PermissionDeniedNoticePreview() {
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ PermissionDeniedNotice {}
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaBrowserScreenLoadingPreview() {
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ MediaBrowserScreenContent(MediaUiState.Loading)
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaBrowserScreenSuccessPreview() {
+ val testMediaList = listOf(
+ MediaItem(
+ id = 1L,
+ uri = "content://media/external/images/media/1".toUri(),
+ name = "Test image.jpg",
+ type = MediaType.Image,
+ path = "/storage/emulated/0/Pictures/Test image.jpg",
+ dateModified = 1767877545000L
+ ),
+ MediaItem(
+ id = 2L,
+ uri = "content://media/external/video/media/2".toUri(),
+ name = "Test video.mkv",
+ type = MediaType.Video,
+ path = "/storage/emulated/0/Movies/Test video.mkv",
+ dateModified = 1767865330000L
+ ),
+ MediaItem(
+ id = 3L,
+ uri = "content://media/external/audio/media/3".toUri(),
+ name = "Test audio.flac",
+ type = MediaType.Audio,
+ path = "/storage/emulated/0/Music/Recordings/Test audio.flac",
+ dateModified = 1767856522000L
+ )
+ )
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ MediaBrowserScreenContent(MediaUiState.Success(testMediaList))
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaBrowserScreenEmptyPreview() {
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ MediaBrowserScreenContent(MediaUiState.Empty)
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaBrowserScreenErrorPreview() {
+ AndroidTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ MediaBrowserScreenContent(MediaUiState.Error("Test error"))
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaRepository.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaRepository.kt
new file mode 100644
index 0000000000..1dc3c14a7b
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaRepository.kt
@@ -0,0 +1,97 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// From code generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.feature.browse
+
+import android.annotation.SuppressLint
+import android.content.ContentUris
+import android.content.Context
+import android.net.Uri
+import android.provider.MediaStore
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.mediaarea.mediainfo.tv.feature.settings.SortOrder
+import kotlin.time.Duration.Companion.seconds
+
+data class MediaItem(
+ val id: Long,
+ val uri: Uri,
+ val name: String,
+ val type: MediaType,
+ val path: String,
+ val dateModified: Long
+)
+
+enum class MediaType { Image, Video, Audio }
+
+class MediaRepository(private val context: Context) {
+
+ @SuppressLint("InlinedApi")
+ suspend fun fetchMedia(
+ sortOrderSetting: SortOrder,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+ ): List = withContext(dispatcher) {
+
+ val mediaList = mutableListOf()
+
+ val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+
+ val projection = arrayOf(
+ MediaStore.Files.FileColumns._ID,
+ MediaStore.Files.FileColumns.DISPLAY_NAME,
+ MediaStore.Files.FileColumns.MEDIA_TYPE,
+ MediaStore.Files.FileColumns.DATA,
+ MediaStore.Files.FileColumns.DATE_MODIFIED
+ )
+
+ val selection = "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (?, ?, ?)"
+ val selectionArgs = arrayOf(
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(),
+ MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO.toString()
+ )
+
+ val sortOrder = when (sortOrderSetting) {
+ SortOrder.DATE -> "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
+ SortOrder.PATH -> "${MediaStore.Files.FileColumns.DATA} ASC"
+ SortOrder.NAME -> "${MediaStore.Files.FileColumns.DISPLAY_NAME} ASC"
+ }
+
+ context.contentResolver.query(
+ collection,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
+ val typeColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
+ val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA)
+ val dateModifiedColumn =
+ cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val name = cursor.getString(nameColumn)
+ val type = when (cursor.getInt(typeColumn)) {
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaType.Video
+ MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO -> MediaType.Audio
+ else -> MediaType.Image
+ }
+ val path = cursor.getString(pathColumn)
+ val dateModified = cursor.getLong(dateModifiedColumn).seconds.inWholeMilliseconds
+ val contentUri = ContentUris.withAppendedId(collection, id)
+
+ mediaList.add(MediaItem(id, contentUri, name, type, path, dateModified))
+ }
+ }
+ mediaList
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaViewModel.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaViewModel.kt
new file mode 100644
index 0000000000..b5888356da
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/browse/MediaViewModel.kt
@@ -0,0 +1,57 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// Generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.feature.browse
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsRepository
+
+class MediaViewModel(
+ private val repository: MediaRepository,
+ private val settingsRepository: SettingsRepository
+) : ViewModel() {
+
+ private var loadJob: Job? = null
+ private val _uiState = MutableStateFlow(MediaUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun loadMedia() {
+ // Check if the job is already active; if so, do nothing
+ // Prevent accidental parallel runs that are unnecessary and may cause race conditions
+ if (loadJob?.isActive == true) return
+
+ loadJob = viewModelScope.launch {
+ _uiState.value = MediaUiState.Loading
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val items = repository.fetchMedia(settingsRepository.settingsFlow.first().sortOrder)
+ if (items.isEmpty()) {
+ _uiState.value = MediaUiState.Empty
+ } else {
+ _uiState.value = MediaUiState.Success(items)
+ }
+ } catch (e: Exception) {
+ _uiState.value = MediaUiState.Error(e.message ?: "Unknown Error")
+ }
+ }
+ }
+}
+
+sealed class MediaUiState {
+ object Loading : MediaUiState()
+ object Empty : MediaUiState()
+ data class Success(val items: List) : MediaUiState()
+ data class Error(val message: String) : MediaUiState()
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportActivity.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportActivity.kt
new file mode 100644
index 0000000000..ee9288574e
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportActivity.kt
@@ -0,0 +1,204 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.feature.report
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.KeyEvent
+import android.webkit.WebView
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
+import net.mediaarea.mediainfo.tv.MediaInfoApplication
+import net.mediaarea.mediainfo.tv.R
+import net.mediaarea.mediainfo.tv.ui.theme.AndroidTheme
+
+// Extend from AppCompatActivity instead of ComponentActivity for WebView dark mode
+class ReportActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ ReportView(intent)
+ }
+ }
+}
+
+private fun Context.findActivity(): ComponentActivity? = when (this) {
+ is ComponentActivity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+@Composable
+private fun ReportView(intent: Intent) {
+ val context = LocalContext.current
+ val app = context.applicationContext as MediaInfoApplication
+ val viewModel: ReportViewModel = viewModel(factory = app.factory)
+ val isDark by viewModel.darkModeEnabled.collectAsStateWithLifecycle()
+ val fileName: String? = intent.getStringExtra("FILE_NAME")
+ val toastMessage = stringResource(R.string.please_wait)
+ val isLoading = viewModel.isLoading
+ val report = viewModel.report
+
+ // Prevent leaving activity while loading
+ // since cancelling MediaInfo's parsing is not implemented
+ BackHandler(enabled = isLoading) {
+ Toast.makeText(
+ context,
+ toastMessage,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ // Parsing
+ LaunchedEffect(Unit) {
+ val uri: Uri? = intent.data
+ val completeName: String? = intent.getStringExtra("COMPLETE_NAME")
+ viewModel.generateReport(uri, completeName)
+ }
+
+ // Report display
+ AndroidTheme(isInDarkTheme = isDark) {
+ Report(isLoading, report, fileName)
+ }
+}
+
+@Composable
+private fun Report(isLoading: Boolean, report: String?, fileName: String?) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ Column {
+ Box(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceColorAtElevation(0.5.dp))
+ .fillMaxWidth()
+ .padding(5.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(R.string.app_name) + " - " + fileName)
+ }
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ if (report == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("Something unexpected has occurred.")
+ }
+ } else {
+ WebViewComposable(report)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WebViewComposable(report: String) {
+ val context = LocalContext.current
+ val focusRequester = remember { FocusRequester() }
+ AndroidView(
+ modifier = Modifier
+ .fillMaxSize()
+ .focusable()
+ .focusRequester(focusRequester)
+ .onKeyEvent { keyEvent ->
+ // Finish the activity immediately on back pressed
+ // Without this, it will require two back presses to exit report activity
+ // because the first will remove focus from WebView and only the second will go back
+ if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK &&
+ keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP
+ ) {
+
+ context.findActivity()?.finish()
+
+ return@onKeyEvent true
+ }
+ false // Let other handlers deal with it
+ },
+ factory = { ctx ->
+ WebView(ctx).apply {
+ // use loadDataWithBaseURL instead of loadData to prevent issues from special characters
+ loadDataWithBaseURL(null, report, "text/html", "utf-8", null)
+ }
+ }
+ )
+ // Get focus on WebView immediately on launch
+ // Without this, it will require an enter press before
+ // the WebView gains focus and can be scrolled with arrow keys
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun ReportPreviewLoading() {
+ AndroidTheme(isInDarkTheme = true) {
+ Report(true, null, "Test.mkv")
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun ReportPreviewLoaded() {
+ AndroidTheme(isInDarkTheme = true) {
+ Report(false, "", "Test.mkv")
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun ReportPreviewUnexpected() {
+ AndroidTheme(isInDarkTheme = true) {
+ Report(false, null, "Test.mkv")
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportViewModel.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportViewModel.kt
new file mode 100644
index 0000000000..97e4780712
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/report/ReportViewModel.kt
@@ -0,0 +1,79 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.feature.report
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mediaarea.mediainfo.tv.Core
+import net.mediaarea.mediainfo.tv.feature.settings.FLOW_TIMEOUT
+import net.mediaarea.mediainfo.tv.feature.settings.SettingsRepository
+
+class ReportViewModel(
+ private val contentResolver: ContentResolver,
+ private val repository: SettingsRepository
+) : ViewModel() {
+
+ private var wasTriggered = false
+ var isLoading by mutableStateOf(true)
+ var report: String? by mutableStateOf(null)
+
+ val darkModeEnabled: StateFlow = repository.settingsFlow
+ .map { it.darkModeEnabled }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(FLOW_TIMEOUT),
+ initialValue = true
+ )
+
+ fun generateReport(
+ uri: Uri?,
+ completeName: String?,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+ ) {
+ // Prevent parallel/repeated execution
+ if (wasTriggered) return
+
+ wasTriggered = true
+
+ viewModelScope.launch(dispatcher) {
+ val settingsFlowFirst = repository.settingsFlow.first()
+ val legacyStreamDisplayEnabled = settingsFlowFirst.legacyStreamDisplayEnabled
+ val displayCaptions = settingsFlowFirst.displayCaptions
+ var fd: ParcelFileDescriptor? = null
+ if (uri != null) {
+ try {
+ fd = contentResolver.openFileDescriptor(uri, "r")
+ } catch (_: Exception) {
+ }
+ }
+ if (fd != null && completeName != null) {
+ report =
+ Core.generateReport(
+ fd.detachFd(),
+ completeName,
+ legacyStreamDisplayEnabled,
+ displayCaptions
+ )
+ }
+ isLoading = false
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/Settings.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/Settings.kt
new file mode 100644
index 0000000000..82e129b476
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/Settings.kt
@@ -0,0 +1,149 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.feature.settings
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import net.mediaarea.mediainfo.tv.MediaInfoApplication
+import net.mediaarea.mediainfo.tv.R
+import net.mediaarea.mediainfo.tv.ui.components.CategoryHeader
+import net.mediaarea.mediainfo.tv.ui.components.MultiOptionListItem
+import net.mediaarea.mediainfo.tv.ui.components.ToggleListItem
+import net.mediaarea.mediainfo.tv.ui.theme.AndroidTheme
+
+@Composable
+fun Settings() {
+ val app = LocalContext.current.applicationContext as MediaInfoApplication
+ val viewModel: SettingsViewModel = viewModel(factory = app.factory)
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ SettingsContent(
+ state = state,
+ onEvent = { viewModel.onEvent(it) }
+ )
+}
+
+@Composable
+private fun SettingsContent(
+ state: MediaInfoSettings,
+ onEvent: (SettingsEvent) -> Unit
+) {
+ LazyColumn(contentPadding = PaddingValues(32.dp)) {
+ item {
+ Text(
+ modifier = Modifier.padding(bottom = 15.dp),
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ }
+ item {
+ CategoryHeader(stringResource(R.string.header_appearance))
+ }
+ item {
+ ToggleListItem(
+ title = stringResource(R.string.dark_mode),
+ description = if (state.darkModeEnabled)
+ stringResource(R.string.enabled)
+ else
+ stringResource(R.string.disabled),
+ isChecked = state.darkModeEnabled,
+ onToggle = { onEvent(SettingsEvent.ToggleDarkMode(it)) }
+ )
+ }
+ item {
+ val optionsLabels = SortOrder.entries.toTypedArray().map { stringResource(it.labelRes) }
+ val selectedIndex = SortOrder.entries.toTypedArray().indexOf(state.sortOrder)
+ MultiOptionListItem(
+ label = stringResource(R.string.file_sort_order),
+ supportingText = stringResource(R.string.file_sort_order_desc),
+ options = optionsLabels,
+ selectedIndex = selectedIndex,
+ onOptionChanged = { onEvent(SettingsEvent.CycleSortOrder) }
+ )
+ }
+ item {
+ CategoryHeader(stringResource(R.string.header_mediainfolib))
+ }
+ item {
+ ToggleListItem(
+ title = stringResource(R.string.legacy_stream_display),
+ description = stringResource(R.string.legacy_stream_display_desc),
+ isChecked = state.legacyStreamDisplayEnabled,
+ onToggle = { onEvent(SettingsEvent.ToggleLegacyStreamDisplay(it)) }
+ )
+ }
+ item {
+ val options = DisplayCaptions.entries.toTypedArray().map { stringResource(it.labelRes) }
+ val selectedIdx = DisplayCaptions.entries.toTypedArray().indexOf(state.displayCaptions)
+ MultiOptionListItem(
+ label = stringResource(R.string.display_captions),
+ supportingText = stringResource(R.string.display_captions_desc),
+ options = options,
+ selectedIndex = selectedIdx,
+ onOptionChanged = { onEvent(SettingsEvent.CycleDisplayCaptions) }
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun SettingsContentPreviewDark() {
+ val isDark = true
+ AndroidTheme(isDark) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ SettingsContent(
+ state = MediaInfoSettings(
+ darkModeEnabled = isDark,
+ sortOrder = SortOrder.DATE,
+ legacyStreamDisplayEnabled = false,
+ displayCaptions = DisplayCaptions.COMMAND
+ ),
+ onEvent = {} // Empty lambda for preview
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun SettingsContentPreviewLight() {
+ val isDark = false
+ AndroidTheme(isDark) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ SettingsContent(
+ state = MediaInfoSettings(
+ darkModeEnabled = isDark,
+ sortOrder = SortOrder.DATE,
+ legacyStreamDisplayEnabled = true,
+ displayCaptions = DisplayCaptions.COMMAND
+ ),
+ onEvent = {} // Empty lambda for preview
+ )
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsRepository.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsRepository.kt
new file mode 100644
index 0000000000..88d3a932e6
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsRepository.kt
@@ -0,0 +1,92 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// From code generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.feature.settings
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.datastore.core.IOException
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import net.mediaarea.mediainfo.tv.R
+
+private val Context.dataStore by preferencesDataStore(name = "settings")
+
+data class MediaInfoSettings(
+ val darkModeEnabled: Boolean,
+ val legacyStreamDisplayEnabled: Boolean,
+ val sortOrder: SortOrder,
+ val displayCaptions: DisplayCaptions,
+)
+
+enum class SortOrder(val key: String, @get:StringRes val labelRes: Int) {
+ DATE("date_modified", R.string.date_modified),
+ PATH("file_path", R.string.file_path),
+ NAME("file_name", R.string.file_name);
+
+ companion object {
+ fun fromKey(key: String?) = entries.find { it.key == key } ?: DATE
+ }
+}
+
+enum class DisplayCaptions(val key: String, @get:StringRes val labelRes: Int) {
+ CONTENT("Content", R.string.display_captions_content),
+ COMMAND("Command", R.string.display_captions_command),
+ STREAM("Stream", R.string.display_captions_stream);
+
+ companion object {
+ fun fromKey(key: String?) = entries.find { it.key == key } ?: COMMAND
+ }
+}
+
+class SettingsRepository(private val context: Context) {
+ private companion object {
+ val darkModeKey = booleanPreferencesKey("dark_mode")
+ val legacyStreamDisplayKey = booleanPreferencesKey("legacy_stream_display")
+ val sortOrderKey = stringPreferencesKey("file_sort_order")
+ val displayCaptionsKey = stringPreferencesKey("display_captions")
+ }
+
+ val settingsFlow: Flow = context.dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }
+ .map { prefs ->
+ MediaInfoSettings(
+ darkModeEnabled = prefs[darkModeKey] ?: true,
+ legacyStreamDisplayEnabled = prefs[legacyStreamDisplayKey] ?: false,
+ sortOrder = SortOrder.fromKey(prefs[sortOrderKey]),
+ displayCaptions = DisplayCaptions.fromKey(prefs[displayCaptionsKey])
+ )
+ }
+
+ suspend fun updateSetting(key: Preferences.Key, value: T) {
+ context.dataStore.edit { prefs ->
+ prefs[key] = value
+ }
+ }
+
+ suspend fun updateDarkMode(enabled: Boolean) =
+ updateSetting(darkModeKey, enabled)
+
+ suspend fun updateLegacyStreamDisplay(enabled: Boolean) =
+ updateSetting(legacyStreamDisplayKey, enabled)
+
+ suspend fun updateSortOrder(sortOrder: SortOrder) =
+ updateSetting(sortOrderKey, sortOrder.key)
+
+ suspend fun updateDisplayCaptions(displayCaptions: DisplayCaptions) =
+ updateSetting(displayCaptionsKey, displayCaptions.key)
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsViewModel.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsViewModel.kt
new file mode 100644
index 0000000000..a2863d3426
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/feature/settings/SettingsViewModel.kt
@@ -0,0 +1,70 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// From code generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.feature.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+const val FLOW_TIMEOUT = 5000L
+
+class SettingsViewModel(private val repository: SettingsRepository) : ViewModel() {
+
+ val uiState: StateFlow = repository.settingsFlow
+ .stateIn(
+ scope = viewModelScope,
+ // WhileSubscribed(5000) keeps the flow active for 5s after the UI stops listening
+ // (handles configuration changes like rotation)
+ started = SharingStarted.WhileSubscribed(FLOW_TIMEOUT),
+ initialValue = MediaInfoSettings(
+ darkModeEnabled = true,
+ sortOrder = SortOrder.fromKey(null),
+ legacyStreamDisplayEnabled = false,
+ displayCaptions = DisplayCaptions.fromKey(null)
+ )
+ )
+
+ fun onEvent(event: SettingsEvent) {
+ viewModelScope.launch {
+ when (event) {
+ is SettingsEvent.CycleSortOrder -> {
+ val allOptions = SortOrder.entries.toTypedArray()
+ val currentIndex = allOptions.indexOf(uiState.value.sortOrder)
+ val nextIndex = (currentIndex + 1) % allOptions.size
+ repository.updateSortOrder(allOptions[nextIndex])
+ }
+
+ is SettingsEvent.CycleDisplayCaptions -> {
+ val allOptions = DisplayCaptions.entries.toTypedArray()
+ val currentIndex = allOptions.indexOf(uiState.value.displayCaptions)
+ val nextIndex = (currentIndex + 1) % allOptions.size
+ repository.updateDisplayCaptions(allOptions[nextIndex])
+ }
+
+ is SettingsEvent.ToggleDarkMode -> {
+ repository.updateDarkMode(event.enabled)
+ }
+
+ is SettingsEvent.ToggleLegacyStreamDisplay -> {
+ repository.updateLegacyStreamDisplay(event.enabled)
+ }
+ }
+ }
+ }
+}
+
+sealed class SettingsEvent {
+ object CycleSortOrder : SettingsEvent()
+ object CycleDisplayCaptions : SettingsEvent()
+ data class ToggleDarkMode(val enabled: Boolean) : SettingsEvent()
+ data class ToggleLegacyStreamDisplay(val enabled: Boolean) : SettingsEvent()
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/CategoryHeader.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/CategoryHeader.kt
new file mode 100644
index 0000000000..5328b3fdbf
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/CategoryHeader.kt
@@ -0,0 +1,79 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// Generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import androidx.tv.material3.darkColorScheme
+
+/**
+ * A section header for TV settings groups.
+ * Includes built-in top spacing to visually separate it from the preceding category.
+ *
+ * @param title The text to display as the category name.
+ * @param modifier Modifier for the root [Column].
+ */
+@Composable
+fun CategoryHeader(
+ title: String,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .padding(horizontal = 16.dp)
+ .padding(top = 24.dp, bottom = 8.dp) // Built-in spacing
+ ) {
+ Text(
+ text = title.uppercase(), // Uppercase often helps distinguish headers on TV
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ letterSpacing = 1.2.sp,
+ modifier = Modifier.alpha(0.6f)
+ )
+ }
+}
+
+@Preview(
+ name = "Full Screen TV Settings (Dark 4K)",
+ device = "id:tv_4k"
+)
+@Composable
+private fun CategoryHeaderDarkPreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column {
+ CategoryHeader(title = "Network & Internet")
+
+ Text(
+ text = "Wi-Fi: Connected",
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ CategoryHeader(title = "System Update")
+
+ Text(
+ text = "Check for updates",
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MediaListItem.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MediaListItem.kt
new file mode 100644
index 0000000000..e25f668945
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MediaListItem.kt
@@ -0,0 +1,105 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import androidx.tv.material3.darkColorScheme
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * A TV ListItem for displaying media items.
+ *
+ * @param name The headlineContent of the item such as the file name.
+ * @param icon An ImageVector to be used as the leadingContent.
+ * @param path The path of the item shown as supportingContent.
+ * @param date The date of the item as the number of milliseconds since January 1, 1970, 00:00:00 GMT.
+ * @param onClick Function to be executed when the item is clicked.
+ */
+@Composable
+fun MediaListItem(
+ name: String,
+ icon: ImageVector,
+ path: String,
+ date: Long,
+ onClick: () -> Unit
+) {
+ // ListItem automatically animates scale/color on focus
+ ListItem(
+ selected = false,
+ onClick = onClick,
+ headlineContent = {
+ Text(text = name, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ supportingContent = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = path,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+ .format(Date(date)),
+ modifier = Modifier.padding(start = 5.dp),
+ maxLines = 1
+ )
+ }
+ },
+ leadingContent = {
+ Icon(imageVector = icon, contentDescription = null)
+ }
+ )
+}
+
+@Preview(showBackground = true, device = "id:tv_4k")
+@Composable
+private fun MediaListItemPreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ Surface {
+ LazyColumn(
+ contentPadding = PaddingValues(32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ MediaListItem(
+ name = "Test audio.flac",
+ icon = Icons.Default.MusicNote,
+ path = "/storage/emulated/0/Music/Recordings/Test audio.flac",
+ date = 1767856522000L,
+ onClick = {}
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MultiOptionListItem.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MultiOptionListItem.kt
new file mode 100644
index 0000000000..ffc7d2420d
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/MultiOptionListItem.kt
@@ -0,0 +1,154 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// Generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import androidx.tv.material3.darkColorScheme
+
+/**
+ * A TV-optimized settings row that cycles through multiple options.
+ *
+ * @param label The primary title of the setting.
+ * @param options A list of strings representing the available choices.
+ * @param selectedIndex The index of the currently active option.
+ * @param onOptionChanged Callback triggered with the new index when the user clicks.
+ * @param modifier Modifier for the root [ListItem].
+ * @param supportingText Optional description string displayed below the label.
+ */
+@Suppress("LongParameterList")
+@Composable
+fun MultiOptionListItem(
+ label: String,
+ options: List,
+ selectedIndex: Int,
+ onOptionChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ supportingText: String? = null
+) {
+ var isFocused by remember { mutableStateOf(false) }
+ ListItem(
+ selected = false,
+ onClick = {
+ val nextIndex = (selectedIndex + 1) % options.size
+ onOptionChanged(nextIndex)
+ },
+ headlineContent = {
+ Text(text = label)
+ },
+ supportingContent = supportingText?.let {
+ {
+ Text(
+ text = it
+ )
+ }
+ },
+ trailingContent = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Use LocalContentColor for the arrows (they will naturally dim/brighten)
+ val iconColor = LocalContentColor.current.copy(alpha = 0.6f)
+
+ Icon(
+ imageVector = Icons.Default.ChevronLeft,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = iconColor
+ )
+
+ Text(
+ text = options[selectedIndex],
+ style = MaterialTheme.typography.labelLarge,
+ color = if (isFocused) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ )
+
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = iconColor
+ )
+ }
+ },
+ modifier = modifier.onFocusChanged { isFocused = it.isFocused }
+ )
+}
+
+@Preview(
+ name = "MultiOptionListItem 4K Static",
+ device = "id:tv_4k",
+)
+@Composable
+private fun MultiOptionListItemStaticPreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ Surface(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Example 1: Standard usage
+ MultiOptionListItem(
+ label = "Screen Resolution",
+ options = listOf("1080p", "4K (Recommended)", "720p"),
+ selectedIndex = 1,
+ onOptionChanged = {}
+ )
+
+ // Example 2: With supporting text
+ MultiOptionListItem(
+ label = "Language",
+ options = listOf("English", "Español", "Français"),
+ selectedIndex = 0,
+ onOptionChanged = {},
+ supportingText = "Choose your preferred system language"
+ )
+
+ // Example 3: Long labels to check text wrapping/truncation
+ MultiOptionListItem(
+ label = "High Dynamic Range (HDR) Display Mode",
+ options = listOf("Always On", "Adaptive", "Off"),
+ selectedIndex = 1,
+ onOptionChanged = {},
+ supportingText = "HDR improves the range of color and contrast"
+ )
+ }
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/ToggleListItem.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/ToggleListItem.kt
new file mode 100644
index 0000000000..57b2e02221
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/components/ToggleListItem.kt
@@ -0,0 +1,108 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+// Generated by Google Gemini 3 Flash
+
+package net.mediaarea.mediainfo.tv.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Switch
+import androidx.tv.material3.Text
+import androidx.tv.material3.darkColorScheme
+
+/**
+ * A specialized list item for TV interfaces that displays a title,
+ * description, and a trailing [Switch].
+ *
+ * This component is optimized for D-pad navigation. By handling the
+ * click at the [ListItem] level and setting the Switch's `onCheckedChange`
+ * to null, the entire row becomes a single focusable target, preventing
+ * "double-focus" issues common on TV platforms.
+ *
+ * @param title The primary text to be displayed in the headline.
+ * @param description The secondary text providing additional context.
+ * @param isChecked Whether the switch is currently in the 'on' or 'off' state.
+ * @param onToggle Lambda invoked when the list item is clicked,
+ * returning the new desired state of the toggle.
+ */
+@Composable
+fun ToggleListItem(
+ title: String,
+ description: String,
+ isChecked: Boolean,
+ onToggle: (Boolean) -> Unit
+) {
+ ListItem(
+ selected = false,
+ onClick = { onToggle(!isChecked) },
+ headlineContent = { Text(title) },
+ supportingContent = { Text(description) },
+ trailingContent = {
+ Switch(
+ checked = isChecked,
+ onCheckedChange = null // Critical for TV: the Row captures the click
+ )
+ }
+ )
+}
+
+@Preview(
+ name = "ToggleListItem 4K Gallery",
+ device = "id:tv_4k"
+)
+@Composable
+private fun ToggleListItemPreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ Surface(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Picture & Inputs",
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ // Example 1: Standard TV Toggle
+ ToggleListItem(
+ title = "Game Mode",
+ description = "Bypasses post-processing to reduce input lag for consoles.",
+ isChecked = true,
+ onToggle = {}
+ )
+
+ // Example 2: HDMI specific setting
+ ToggleListItem(
+ title = "HDMI-CEC",
+ description = "Allow the TV to control external devices over HDMI.",
+ isChecked = false,
+ onToggle = {}
+ )
+
+ // Example 3: Connectivity/Privacy setting
+ ToggleListItem(
+ title = "Wake on LAN",
+ description = "Allows the TV to be turned on from standby via the network.",
+ isChecked = true,
+ onToggle = {}
+ )
+ }
+ }
+ }
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Color.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Color.kt
new file mode 100644
index 0000000000..5b50c12980
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Color.kt
@@ -0,0 +1,17 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Theme.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Theme.kt
new file mode 100644
index 0000000000..1cad53723b
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Theme.kt
@@ -0,0 +1,47 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.ui.theme
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.darkColorScheme
+import androidx.tv.material3.lightColorScheme
+
+@Composable
+fun AndroidTheme(
+ isInDarkTheme: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ // Set XML views theme which is used by WebView
+ LaunchedEffect(isInDarkTheme) {
+ if (isInDarkTheme) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+ }
+ }
+ val colorScheme = if (isInDarkTheme) {
+ darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+ )
+ } else {
+ lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+ )
+ }
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Type.kt b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Type.kt
new file mode 100644
index 0000000000..9dab1888b0
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/java/net/mediaarea/mediainfo/tv/ui/theme/Type.kt
@@ -0,0 +1,42 @@
+/* Copyright (c) MediaArea.net SARL. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license that can
+ * be found in the License.html file in the root of the source tree.
+ */
+
+package net.mediaarea.mediainfo.tv.ui.theme
+
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Typography
+
+// Set of Material typography styles to start with
+@OptIn(ExperimentalTvMaterial3Api::class)
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_action_add.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_action_add.xml
new file mode 100644
index 0000000000..62c4bb67f7
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_action_add.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_banner_foreground.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_banner_foreground.xml
new file mode 100644
index 0000000000..d73ef0f369
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_banner_foreground.xml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_foreground.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..240356ce63
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_monochrome.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000000..eece32e415
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_export.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_export.xml
new file mode 100644
index 0000000000..67890c4dec
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_export.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_view.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_view.xml
new file mode 100644
index 0000000000..38cbe99dec
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_menu_view.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/drawable/ic_report_close.xml b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_report_close.xml
new file mode 100644
index 0000000000..b60298d1fe
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/drawable/ic_report_close.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_banner.xml
new file mode 100644
index 0000000000..a0a0dece93
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_banner.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..5ed0a2df70
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..7353dbd1fd
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
new file mode 100644
index 0000000000..10bc7a5de4
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml
new file mode 100644
index 0000000000..1084c24082
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000000..534f2ded0f
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..cbc11800a5
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..daba21594f
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000000..c1f763a64b
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..d4846938bf
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..8df7e05581
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_banner.png b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_banner.png
new file mode 100644
index 0000000000..cb4d7d51e5
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_banner.png differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..d0360ba9ca
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..fbe1a1c572
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..a77eba4e58
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..e5ae134324
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..ed4f00c271
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..7710ff6cdf
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..e904bb8a10
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..c5935bbce7
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..33906d3be2
Binary files /dev/null and b/Source/GUI/Android/tvapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/Source/GUI/Android/tvapp/src/main/res/values/ic_banner_background.xml b/Source/GUI/Android/tvapp/src/main/res/values/ic_banner_background.xml
new file mode 100644
index 0000000000..15db34b811
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/values/ic_banner_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/values/ic_launcher_background.xml b/Source/GUI/Android/tvapp/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000000..c5d5899fdf
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/values/strings.xml b/Source/GUI/Android/tvapp/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..43e0adbc7f
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+ MediaInfo
+ Browse
+ Settings
+ About
+ Loading…
+ No media found in MediaStore
+ Error
+ Storage Access Required
+ To browse your media files, %1$s needs permission to access your local storage.
+ Grant Permission
+ Appearance
+ MediaInfoLib
+ Dark mode
+ Currently enabled
+ Currently disabled
+ File sorting order
+ The sorting order of media files in the media browser screen
+ Date modified
+ File path
+ File name
+ Legacy stream display
+ Display backward compatibility information for HD/HE audio and 3D/HDR video streams
+ Display captions
+ Handling of 608/708 streams
+ When content or a command is detected
+ When content is detected
+ Even when no content or command is detected
+ MediaInfo v%MI_VERSION% using MediaInfoLib v%MIL_VERSION%
+ Copyright © 2002-2025 MediaArea.net SARL.
+
+ MediaInfo provides easy access to technical and tag information about video and audio files.\n
+ Except the Mac App Store graphical user interface, it is open-source software, which means that it is free of charge to the end user and developers have freedom to study, to improve and to redistribute the program (BSD license)
+
+ ReportActivity
+ Please wait
+
\ No newline at end of file
diff --git a/Source/GUI/Android/tvapp/src/main/res/values/themes.xml b/Source/GUI/Android/tvapp/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..1fc6040dc9
--- /dev/null
+++ b/Source/GUI/Android/tvapp/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file