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 @@ + + +