diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 85ee408bc..a0ca594fb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -157,7 +157,8 @@ pinterest-ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "
puppycrawl-checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" }
reactivex-rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" }
reactivex-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" }
-russhwolf-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" }
+russhwolf-settings-core = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" }
+russhwolf-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "settings" }
squareup-leakcanary-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" }
squareup-leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" }
squareup-leakcanary-watcher = { module = "com.squareup.leakcanary:leakcanary-object-watcher-android", version.ref = "leakcanary" }
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index f52e7a208..de7c8eb07 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -107,12 +107,13 @@ kotlin {
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.annotations)
- implementation(libs.russhwolf.settings)
+ implementation(libs.russhwolf.settings.core)
}
}
commonTest.dependencies {
implementation(libs.kotlin.test.core)
implementation(libs.jetbrains.compose.test.ui)
+ implementation(libs.russhwolf.settings.test)
}
androidMain.dependencies {
implementation(libs.jetbrains.compose.preview)
diff --git a/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml b/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml
new file mode 100644
index 000000000..fb58b74b1
--- /dev/null
+++ b/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index db4b09e00..74b75e7cd 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -5,4 +5,7 @@
-->
NewPipe
+
+
+ Back
diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt b/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt
new file mode 100644
index 000000000..757f644a1
--- /dev/null
+++ b/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app
+
+object Constants {
+ const val KEY_STREAMING_SERVICE = "service"
+}
diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt
new file mode 100644
index 000000000..989810a55
--- /dev/null
+++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.composable
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import net.newpipe.app.preview.ThemePreviewProvider
+import net.newpipe.app.theme.currentServiceTopAppBarColors
+import newpipe.shared.generated.resources.Res
+import newpipe.shared.generated.resources.app_name
+import newpipe.shared.generated.resources.ic_arrow_back
+import newpipe.shared.generated.resources.navigate_back
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * A top app bar composable to be used with Scaffold
+ * @param modifier The modifier to be applied to the composable
+ * @param title Title of the screen
+ * @param navigationIcon Icon for the navigation button
+ * @param onNavigateUp Action when user clicks the navigation icon
+ * @param actions Actions to display on the top app bar (for e.g. menu)
+ */
+@Composable
+fun TopAppBar(
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ navigationIcon: Painter = painterResource(Res.drawable.ic_arrow_back),
+ onNavigateUp: (() -> Unit)? = null,
+ colors: TopAppBarColors = currentServiceTopAppBarColors(),
+ actions: @Composable (RowScope.() -> Unit) = {}
+) {
+ TopAppBar(
+ modifier = modifier,
+ colors = colors,
+ title = { if (title != null) Text(text = title) },
+ navigationIcon = {
+ if (onNavigateUp != null) {
+ IconButton(onClick = onNavigateUp) {
+ Icon(
+ painter = navigationIcon,
+ contentDescription = stringResource(Res.string.navigate_back)
+ )
+ }
+ }
+ },
+ actions = actions
+ )
+}
+
+@PreviewWrapper(ThemePreviewProvider::class)
+@PreviewLightDark
+@Composable
+private fun TopAppBarPreview() {
+ TopAppBar(
+ title = stringResource(Res.string.app_name),
+ onNavigateUp = {}
+ )
+}
diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt b/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt
new file mode 100644
index 000000000..9c29869b1
--- /dev/null
+++ b/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.preview
+
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewWrapperProvider
+import net.newpipe.app.theme.AppTheme
+
+/**
+ * Default preview provider for composables
+ */
+class ThemePreviewProvider : PreviewWrapperProvider {
+
+ @Composable
+ override fun Wrap(content: @Composable (() -> Unit)) {
+ AppTheme {
+ Surface(content = content)
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt b/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt
new file mode 100644
index 000000000..bc79ab34e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt
@@ -0,0 +1,163 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalInspectionMode
+import com.russhwolf.settings.Settings
+import net.newpipe.app.Constants.KEY_STREAMING_SERVICE
+import org.koin.compose.koinInject
+
+val youTubeLightScheme = lightColorScheme(
+ primaryContainer = Color(0xFFE53935),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val youTubeDarkScheme = darkColorScheme(
+ primaryContainer = Color(0xFF992722),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val soundCloudLightScheme = lightColorScheme(
+ primaryContainer = Color(0xFFF57C00),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val soundCloudDarkScheme = darkColorScheme(
+ primaryContainer = Color(0xFFA35300),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val mediaCCCLightScheme = lightColorScheme(
+ primaryContainer = Color(0xFF9E9E9E),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val mediaCCCDarkScheme = darkColorScheme(
+ primaryContainer = Color(0xFF878787),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val peerTubeLightScheme = lightColorScheme(
+ primaryContainer = Color(0xFFFF6F00),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val peerTubeDarkScheme = darkColorScheme(
+ primaryContainer = Color(0xFFA34700),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val bandCampLightScheme = lightColorScheme(
+ primaryContainer = Color(0xFF17A0C4),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+val bandCampDarkScheme = darkColorScheme(
+ primaryContainer = Color(0xFF1383A1),
+ onPrimaryContainer = Color(0xFFFFFFFF)
+)
+
+/**
+ * Supported services in the NewPipe app and minor information about them for UI decisions.
+ * @property serviceId ID of the service as defined in NewPipeExtractor
+ * @property serviceName Name of the service as defined in NewPipeExtractor
+ * @property lightScheme Light color scheme to reflect the brand
+ * @property darkScheme Dark color scheme to reflect the brand
+ * @property isSchemeColorDensityLight Whether this brand's color schemes are of lighter density.
+ */
+enum class Service(
+ val serviceId: Int,
+ val serviceName: String,
+ val lightScheme: ColorScheme,
+ val darkScheme: ColorScheme,
+ val isSchemeColorDensityLight: Boolean = false
+) {
+ YOUTUBE(
+ serviceId = 0,
+ serviceName = "YouTube",
+ lightScheme = youTubeLightScheme,
+ darkScheme = youTubeDarkScheme
+ ),
+ SOUNDCLOUD(
+ serviceId = 1,
+ serviceName = "SoundCloud",
+ lightScheme = soundCloudLightScheme,
+ darkScheme = soundCloudDarkScheme
+ ),
+ MEDIA_CCC(
+ serviceId = 2,
+ serviceName = "media.ccc.de",
+ lightScheme = mediaCCCLightScheme,
+ darkScheme = mediaCCCDarkScheme
+ ),
+ PEERTUBE(
+ serviceId = 3,
+ serviceName = "PeerTube",
+ lightScheme = peerTubeLightScheme,
+ darkScheme = peerTubeDarkScheme
+ ),
+ BANDCAMP(
+ serviceId = 4,
+ serviceName = "Bandcamp",
+ lightScheme = bandCampLightScheme,
+ darkScheme = bandCampDarkScheme
+ )
+}
+
+/**
+ * Currently active/selected service by user
+ */
+@Composable
+fun currentService(settings: Settings = koinInject()): Service {
+ return Service.entries.find { service ->
+ service.serviceName == settings.getString(
+ KEY_STREAMING_SERVICE,
+ Service.YOUTUBE.serviceName
+ )
+ }!!
+}
+
+/**
+ * Currently active/selected service's color that can be used to represent it.
+ * Fallbacks to YouTube on preview.
+ */
+@Composable
+fun currentServiceScheme(
+ isPreview: Boolean = LocalInspectionMode.current,
+ useDarkTheme: Boolean = isSystemInDarkTheme(),
+ service: Service = if (isPreview) Service.YOUTUBE else currentService()
+): ColorScheme {
+ return when {
+ useDarkTheme -> service.darkScheme
+ else -> service.lightScheme
+ }
+}
+
+/**
+ * Top app bar colors to represent the currently active service.
+ * Fallbacks to YouTube on preview.
+ */
+@Composable
+fun currentServiceTopAppBarColors(
+ serviceScheme: ColorScheme = currentServiceScheme()
+): TopAppBarColors {
+ return TopAppBarDefaults.topAppBarColors(
+ containerColor = serviceScheme.primaryContainer,
+ scrolledContainerColor = serviceScheme.primaryContainer,
+ navigationIconContentColor = serviceScheme.onPrimaryContainer,
+ titleContentColor = serviceScheme.onPrimaryContainer,
+ subtitleContentColor = serviceScheme.onPrimaryContainer,
+ actionIconContentColor = serviceScheme.onPrimaryContainer
+ )
+}
diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt
new file mode 100644
index 000000000..e8dabe314
--- /dev/null
+++ b/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.composable
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.russhwolf.settings.MapSettings
+import com.russhwolf.settings.Settings
+import kotlin.test.Test
+import kotlin.test.assertTrue
+import net.newpipe.app.extensions.withKoin
+import newpipe.shared.generated.resources.Res
+import newpipe.shared.generated.resources.app_name
+import newpipe.shared.generated.resources.navigate_back
+import org.jetbrains.compose.resources.getString
+import org.jetbrains.compose.resources.stringResource
+import org.koin.dsl.module
+
+@OptIn(ExperimentalTestApi::class)
+class TopAppBarTest {
+
+ private val emptySettings = module {
+ single { MapSettings() }
+ }
+
+ @Test
+ fun testTopAppBarHasNoNavigationByDefault() = runComposeUiTest {
+ withKoin(
+ modules = listOf(emptySettings),
+ content = {
+ TopAppBar()
+ },
+ onContent = {
+ onNodeWithContentDescription(getString(Res.string.navigate_back))
+ .assertDoesNotExist()
+ }
+ )
+ }
+
+ @Test
+ fun testTopAppBarCanHaveNavigation() = runComposeUiTest {
+ var navigationBackClicked = false
+ withKoin(
+ modules = listOf(emptySettings),
+ content = {
+ TopAppBar(
+ title = stringResource(Res.string.app_name),
+ onNavigateUp = { navigationBackClicked = true }
+ )
+ },
+ onContent = {
+ onNodeWithText(getString(Res.string.app_name)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(Res.string.navigate_back)).apply {
+ assertExists()
+ performClick()
+ assertTrue(navigationBackClicked)
+ }
+ }
+ )
+ }
+}
diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt
new file mode 100644
index 000000000..7ec719ee8
--- /dev/null
+++ b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.extensions
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import org.koin.compose.KoinApplication
+import org.koin.core.context.stopKoin
+import org.koin.core.logger.Level
+import org.koin.core.module.Module
+import org.koin.dsl.koinConfiguration
+
+/**
+ * Sets the content for the UI test wrapped inside Koin
+ * @param modules Modules for Koin to init for the composables
+ * @param content Composable content for testing
+ * @param onContent Non-composable code for testing, maybe dependent upon composable code
+ */
+@OptIn(ExperimentalTestApi::class)
+inline fun ComposeUiTest.withKoin(
+ modules: List,
+ noinline content: @Composable () -> Unit = {},
+ onContent: () -> Unit = {}
+) {
+ try {
+ setContent {
+ KoinApplication(
+ configuration = koinConfiguration {
+ modules(modules)
+ },
+ logLevel = Level.DEBUG,
+ content = content
+ )
+ }
+ onContent()
+ } finally {
+ stopKoin()
+ }
+}
diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt
new file mode 100644
index 000000000..86e6494ad
--- /dev/null
+++ b/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: 2026 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package net.newpipe.app.theme
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runComposeUiTest
+import com.russhwolf.settings.MapSettings
+import com.russhwolf.settings.Settings
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import net.newpipe.app.Constants.KEY_STREAMING_SERVICE
+import net.newpipe.app.extensions.withKoin
+import org.koin.dsl.module
+
+@OptIn(ExperimentalTestApi::class)
+class ServiceThemeTest {
+
+ @Test
+ fun testDefaultServiceIsYouTube() = runComposeUiTest {
+ val emptySettings = module {
+ single { MapSettings() }
+ }
+
+ withKoin(
+ modules = listOf(emptySettings),
+ content = {
+ assertEquals(currentService(), Service.YOUTUBE)
+ }
+ )
+ }
+
+ @Test
+ fun testServiceSwitchingWorks() = runComposeUiTest {
+ val settings = module {
+ single {
+ MapSettings(KEY_STREAMING_SERVICE to "PeerTube")
+ }
+ }
+
+ withKoin(
+ modules = listOf(settings),
+ content = {
+ assertNotEquals(currentService(), Service.YOUTUBE)
+ assertEquals(currentService(), Service.PEERTUBE)
+ }
+ )
+ }
+}