diff --git a/.github/workflows/branding-check.yml b/.github/workflows/branding-check.yml new file mode 100644 index 00000000..591aabec --- /dev/null +++ b/.github/workflows/branding-check.yml @@ -0,0 +1,45 @@ +name: Check branding build + +on: + pull_request: + paths: + - 'app/src/main/**' + - 'artwork/**' + - 'tools/generate_brand_assets.py' + - 'tools/check_brand_assets.py' + - '.github/workflows/branding-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + debug: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + - uses: android-actions/setup-android@v4 + - uses: gradle/actions/setup-gradle@v4 + - name: Install compile SDK + run: | + sdkmanager --channel=3 --install "platforms;android-37.0" "build-tools;37.0.0" + if [ -d "$ANDROID_HOME/platforms/android-37.0" ] && [ ! -e "$ANDROID_HOME/platforms/android-37" ]; then + ln -s android-37.0 "$ANDROID_HOME/platforms/android-37" + fi + - name: Build debug and run unit tests + run: bash ./gradlew --no-daemon :app:assembleStandardDebug :app:testStandardDebugUnitTest + - name: Check TV banner viewport + run: | + python -m pip install --disable-pip-version-check Pillow + python tools/check_brand_assets.py + - uses: actions/upload-artifact@v4 + with: + name: Aperture-branding-debug + path: app/build/outputs/apk/standard/debug/*.apk + if-no-files-found: error + retention-days: 7 diff --git a/Aperture.png b/Aperture.png index 63f957b5..370cc0d2 100644 Binary files a/Aperture.png and b/Aperture.png differ diff --git a/app/src/main/assets/ic_banner.png b/app/src/main/assets/ic_banner.png index 428b6eca..f29a163e 100644 Binary files a/app/src/main/assets/ic_banner.png and b/app/src/main/assets/ic_banner.png differ diff --git a/app/src/main/assets/res/mipmap-hdpi/ic_launcher.png b/app/src/main/assets/res/mipmap-hdpi/ic_launcher.png index e0670d6f..429ad392 100644 Binary files a/app/src/main/assets/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/assets/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_background.png index 25792b13..1caa2745 100644 Binary files a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_background.png and b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_foreground.png index 9a9f2053..460bd797 100644 Binary files a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_monochrome.png index 41865187..f8972b7d 100644 Binary files a/app/src/main/assets/res/mipmap-hdpi/ic_launcher_monochrome.png and b/app/src/main/assets/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/assets/res/mipmap-mdpi/ic_launcher.png b/app/src/main/assets/res/mipmap-mdpi/ic_launcher.png index c9df4ea6..0710fb6b 100644 Binary files a/app/src/main/assets/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/assets/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_background.png index ddbbc44b..b1d17ffa 100644 Binary files a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_background.png and b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_foreground.png index a57d95db..76be274c 100644 Binary files a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_monochrome.png index ee2bd73a..21fb3b8f 100644 Binary files a/app/src/main/assets/res/mipmap-mdpi/ic_launcher_monochrome.png and b/app/src/main/assets/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher.png index bac4f02c..30f4c560 100644 Binary files a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_background.png index 4219f50f..233eb965 100644 Binary files a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_background.png and b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_foreground.png index 9f710d8c..6278ee99 100644 Binary files a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_monochrome.png index f31defeb..6768e444 100644 Binary files a/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/app/src/main/assets/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher.png index c89c6f31..0471d3e0 100644 Binary files a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_background.png index ddd72a5e..44d3d28a 100644 Binary files a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_background.png and b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_foreground.png index fc9c35cf..746d72ae 100644 Binary files a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_monochrome.png index 816cb280..a6bd0f44 100644 Binary files a/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/app/src/main/assets/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher.png index db352675..265d1041 100644 Binary files a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_background.png index ff958a6b..a07ddd2a 100644 Binary files a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_background.png and b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_foreground.png index a4e219c3..6b99e612 100644 Binary files a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_monochrome.png index 55e550a9..045c348c 100644 Binary files a/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/app/src/main/assets/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index ef2af4dc..8e6724da 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/me/xdan/aperture/MainActivity.kt b/app/src/main/java/me/xdan/aperture/MainActivity.kt index ceef5059..05e71611 100644 --- a/app/src/main/java/me/xdan/aperture/MainActivity.kt +++ b/app/src/main/java/me/xdan/aperture/MainActivity.kt @@ -138,10 +138,11 @@ class MainActivity : ComponentActivity() { themeId = themeId, dynamicAccent = dynamicAccentArgb?.let { androidx.compose.ui.graphics.Color(it) } ) { + val brandMotion = ApertureTheme.motion Box(modifier = Modifier.fillMaxSize()) { AnimatedVisibility( visible = launchFinished, - enter = fadeIn(tween(360)) + scaleIn(tween(420), initialScale = 0.985f) + enter = fadeIn(brandMotion.brandEffects()) + scaleIn(brandMotion.brandSpatial(), initialScale = 0.985f) ) { androidx.tv.material3.Surface( modifier = Modifier.fillMaxSize(), @@ -239,7 +240,7 @@ class MainActivity : ComponentActivity() { AnimatedVisibility( visible = !launchFinished, - exit = fadeOut(tween(260)) + exit = fadeOut(brandMotion.brandFastEffects()) ) { ApertureLaunchAnimation( onFinished = { launchFinished = true } diff --git a/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandArtwork.kt b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandArtwork.kt new file mode 100644 index 00000000..6366517a --- /dev/null +++ b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandArtwork.kt @@ -0,0 +1,221 @@ +package me.xdan.aperture.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +data class ApertureBrandPose( + /** 0=circle, 1=soft square, 2=six-sided cookie. */ + val seedShape: Float = 0f, + val assembly: Float = 1f, + val rotation: Float = 0f, + val scale: Float = 1f, + val overlapAlpha: Float = 1f, + val playScale: Float = 1f, + val playAlpha: Float = 1f +) + +/** + * Shared by launch, drawer and Ambient; the settled paths match artwork/aperture-logo.svg. + * Actual path intersections keep overlap colours attached while the blades morph. + * Reading animation state in the draw phase avoids recomposing the surrounding UI. + */ +@Composable +internal fun ApertureBrandArtwork( + modifier: Modifier = Modifier, + palette: ApertureBrandPalette = currentApertureBrandPalette(), + pose: () -> ApertureBrandPose = { ApertureBrandPose() } +) { + val settledBlades = remember { brandBlades(1f, 0f) } + val settledOverlaps = remember { brandOverlaps(settledBlades) } + val playPath = remember { brandPlayPath() } + Canvas(modifier) { + val state = pose() + val unit = size.minDimension / 100f + val blades = if (state.assembly == 1f) settledBlades + else brandBlades(state.assembly, state.seedShape) + val overlaps = when { + state.overlapAlpha <= 0f -> emptyList() + state.assembly == 1f -> settledOverlaps + else -> brandOverlaps(blades) + } + translate((size.width - 100f * unit) / 2f, (size.height - 100f * unit) / 2f) { + scale(unit, unit, Offset.Zero) { + scale(state.scale, pivot = BrandCentre) { + rotate(state.rotation, BrandCentre) { + if (state.assembly == 0f) { + drawPath(brandMorphPath(state.seedShape, 0f, 0f), palette.blade) + } else { + blades.forEach { drawPath(it, palette.blade) } + overlaps.forEach { + drawPath(it, palette.overlap, alpha = state.overlapAlpha.coerceIn(0f, 1f)) + } + } + } + if (state.playAlpha > 0f && state.playScale > 0f) { + scale(state.playScale, pivot = BrandCentre) { + drawPath(playPath, palette.play, alpha = state.playAlpha.coerceIn(0f, 1f)) + } + } + } + } + } + } +} + +private val BrandCentre = Offset(50f, 50f) + +// Corresponding quadratic segments enable circle → soft square → cookie → blade morphs. +// Lines use their midpoint as the control point, preserving the approved SVG exactly. +private val BladeStart = Offset(54.3f, 19.8f) +private val BladeSegments = listOf( + Offset(68.85f, 19.8f) to Offset(83.4f, 19.8f), + Offset(88.7f, 19.8f) to Offset(89.2f, 23.2f), + Offset(89.6f, 25.3f) to Offset(86.7f, 27.8f), + Offset(78.1f, 35.3f) to Offset(69.5f, 42.8f), + Offset(65f, 47.3f) to Offset(60.5f, 42.8f), + Offset(54.4f, 36.7f) to Offset(48.3f, 30.6f), + Offset(43.7f, 26f) to Offset(47.1f, 22.1f), + Offset(49.2f, 19.8f) to BladeStart +) +private val SquareStart = Offset(42f, 32f) +private val SquareCoarseSegments = listOf( + Offset(50f, 32f) to Offset(58f, 32f), + Offset(68f, 32f) to Offset(68f, 42f), + Offset(68f, 50f) to Offset(68f, 58f), + Offset(68f, 68f) to Offset(58f, 68f), + Offset(50f, 68f) to Offset(42f, 68f), + Offset(32f, 68f) to Offset(32f, 58f), + Offset(32f, 50f) to Offset(32f, 42f), + Offset(32f, 32f) to SquareStart +) + +private fun radialPoint(degrees: Float, radius: Float): Offset { + val angle = degrees * PI / 180.0 + return BrandCentre + Offset(cos(angle).toFloat() * radius, sin(angle).toFloat() * radius) +} + +private val CircleStart = radialPoint(-112.5f, 16f) +private val CircleCoarseSegments = List(8) { index -> + val startAngle = -112.5f + 45f * index + radialPoint(startAngle + 22.5f, 16f / cos(PI / 8).toFloat()) to radialPoint(startAngle + 45f, 16f) +} + +/** Split each existing quadratic into three exact sub-curves for shape correspondence. */ +private fun subdivideQuadratics(start: Offset, segments: List>): List> { + var segmentStart = start + return buildList(segments.size * 3) { + segments.forEach { (control, end) -> + fun pointAt(t: Float): Offset { + val inverse = 1f - t + return segmentStart * (inverse * inverse) + control * (2f * inverse * t) + end * (t * t) + } + var from = 0f + repeat(3) { + val to = (it + 1) / 3f + val fromPoint = pointAt(from) + val toPoint = pointAt(to) + val halfDerivative = (control - segmentStart) * (1f - from) + (end - control) * from + val subControl = fromPoint + halfDerivative * (to - from) + add(subControl to toPoint) + from = to + } + segmentStart = end + } + } +} + +private val SquareSegments = subdivideQuadratics(SquareStart, SquareCoarseSegments) +private val CircleSegments = subdivideQuadratics(CircleStart, CircleCoarseSegments) +private val BladeMorphSegments = subdivideQuadratics(BladeStart, BladeSegments) + +/** Material 3 Expressive's six-point cookie: alternating outer and inner radii, softly rounded. */ +private val CookiePoints = List(12) { index -> + val angle = -90.0 + 30.0 * index + val radius = if (index % 2 == 0) 18f else 13.5f + radialPoint(angle.toFloat(), radius) +} +private val CookieIncoming = CookiePoints.indices.map { index -> + val point = CookiePoints[index] + val previous = CookiePoints[(index + CookiePoints.size - 1) % CookiePoints.size] + interpolate(point, previous, 0.34f) +} +private val CookieOutgoing = CookiePoints.indices.map { index -> + val point = CookiePoints[index] + val next = CookiePoints[(index + 1) % CookiePoints.size] + interpolate(point, next, 0.34f) +} +private val CookieStart = CookieIncoming.first() +private val CookieSegments = buildList(24) { + CookiePoints.indices.forEach { index -> + add(CookiePoints[index] to CookieOutgoing[index]) + val next = (index + 1) % CookiePoints.size + add(interpolate(CookieOutgoing[index], CookieIncoming[next], 0.5f) to CookieIncoming[next]) + } +} + +private val CircleMorphStart = CircleStart +private val SquareMorphStart = SquareStart +private val BladeMorphStart = BladeStart + +private fun interpolate(a: Offset, b: Offset, progress: Float) = a + (b - a) * progress + +private fun seedPoint(circle: Offset, square: Offset, cookie: Offset, phase: Float): Offset = + if (phase <= 1f) interpolate(circle, square, phase.coerceIn(0f, 1f)) + else interpolate(square, cookie, (phase - 1f).coerceIn(0f, 1f)) + +private fun rotated(point: Offset, degrees: Float): Offset { + val radians = degrees * PI / 180.0 + val x = point.x - 50f + val y = point.y - 50f + return Offset( + 50f + x * cos(radians).toFloat() - y * sin(radians).toFloat(), + 50f + x * sin(radians).toFloat() + y * cos(radians).toFloat() + ) +} + +@Suppress("DEPRECATION") +private fun brandMorphPath(seedShape: Float, assembly: Float, rotation: Float) = Path().apply { + fun point(circle: Offset, square: Offset, cookie: Offset, blade: Offset): Offset = rotated( + interpolate(seedPoint(circle, square, cookie, seedShape), blade, assembly), rotation + ) + val start = point(CircleMorphStart, SquareMorphStart, CookieStart, BladeMorphStart) + moveTo(start.x, start.y) + repeat(CookieSegments.size) { index -> + val control = point(CircleSegments[index].first, SquareSegments[index].first, CookieSegments[index].first, BladeMorphSegments[index].first) + val end = point(CircleSegments[index].second, SquareSegments[index].second, CookieSegments[index].second, BladeMorphSegments[index].second) + quadraticBezierTo(control.x, control.y, end.x, end.y) + } + close() +} + +private fun brandBlades(assembly: Float, seedShape: Float) = + List(6) { brandMorphPath(seedShape, assembly, it * 60f) } + +private fun brandOverlaps(blades: List) = List(6) { index -> + Path().apply { op(blades[index], blades[(index + 1) % 6], PathOperation.Intersect) } +} + +@Suppress("DEPRECATION") +private fun brandPlayPath() = Path().apply { + moveTo(45.2f, 38.5f) + lineTo(62.1f, 47.7f) + quadraticBezierTo(66.2f, 50f, 62.1f, 52.3f) + lineTo(45.2f, 61.5f) + quadraticBezierTo(42f, 63.2f, 41.2f, 59.8f) + quadraticBezierTo(41f, 59.1f, 41f, 57.6f) + lineTo(41f, 42.4f) + quadraticBezierTo(41f, 40.9f, 41.2f, 40.2f) + quadraticBezierTo(42f, 36.8f, 45.2f, 38.5f) + close() +} diff --git a/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMark.kt b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMark.kt index d9d1cc2c..456d2bfa 100644 --- a/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMark.kt +++ b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMark.kt @@ -1,135 +1,32 @@ package me.xdan.aperture.ui.component import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.rotate -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate import me.xdan.aperture.ui.theme.ApertureTheme -private const val BladeSpinDurationMillis = 650 -private val BladeSpinEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) - -/** A glass Aperture mark coloured by the active app theme. */ +/** The approved opaque mark. The play symbol stays still while the shutter turns. */ @Composable -fun ApertureBrandMark( +internal fun ApertureBrandMark( modifier: Modifier = Modifier, - accent: Color = ApertureTheme.brandAccent, spinBlades: Boolean = false, - spinKey: Any? = null + spinKey: Any? = null, + palette: ApertureBrandPalette = currentApertureBrandPalette() ) { - val bladeRotation = remember { Animatable(0f) } - val bladePath = remember { apertureBrandBladePath() } - val playPath = remember { apertureBrandPlayPath() } - val bladeBrush = remember(accent) { - Brush.linearGradient( - colorStops = arrayOf( - 0f to Color.White.copy(alpha = 0.32f), - 0.24f to accent.copy(alpha = 0.58f), - 0.72f to accent.copy(alpha = 0.19f), - 1f to Color.White.copy(alpha = 0.07f) - ), - start = Offset(35f, 18f), - end = Offset(76f, 67f) - ) - } - val bladeStroke = mixBrandColor(accent, Color.White, 0.46f) - + val motion = ApertureTheme.motion + val rotation = remember { Animatable(0f) } LaunchedEffect(spinBlades, spinKey) { if (spinBlades) { - bladeRotation.snapTo(0f) - bladeRotation.animateTo( - targetValue = 360f, - animationSpec = tween( - durationMillis = BladeSpinDurationMillis, - easing = BladeSpinEasing - ) - ) + rotation.snapTo(0f) + rotation.animateBrandTurn(motion) } else { - // Zero and 360 degrees are visually identical; resetting here primes the next opening. - bladeRotation.snapTo(0f) + // 0° and 360° are visually identical; reset to prime the next spin. + rotation.snapTo(0f) } } - - Canvas(modifier = modifier) { - val unit = size.minDimension / 100f - scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) { - rotate(degrees = bladeRotation.value, pivot = ApertureBrandCentre) { - repeat(6) { index -> - rotate(degrees = index * 60f, pivot = ApertureBrandCentre) { - translate(top = 1.1f) { - drawPath( - path = bladePath, - color = Color.Black, - alpha = 0.24f - ) - } - drawPath( - path = bladePath, - brush = bladeBrush, - alpha = 0.84f, - blendMode = BlendMode.Screen - ) - drawPath( - path = bladePath, - color = bladeStroke, - alpha = 0.9f, - style = Stroke(width = 1.15f, join = StrokeJoin.Round) - ) - } - } - } - drawPath(path = playPath, color = Color.White) - } + ApertureBrandArtwork(modifier = modifier, palette = palette) { + ApertureBrandPose(rotation = rotation.value) } } - -private val ApertureBrandCentre = Offset(50f, 50f) - -@Suppress("DEPRECATION") -private fun apertureBrandBladePath() = Path().apply { - moveTo(54f, 20f) - lineTo(85.2f, 20f) - quadraticBezierTo(88.5f, 20f, 89.7f, 22.5f) - quadraticBezierTo(90.5f, 24.3f, 88.1f, 26.7f) - lineTo(68.7f, 43.1f) - quadraticBezierTo(65f, 46.8f, 61.3f, 43.1f) - lineTo(48.1f, 29.9f) - quadraticBezierTo(44.3f, 26.1f, 47f, 22.3f) - quadraticBezierTo(49f, 20f, 54f, 20f) - close() -} - -@Suppress("DEPRECATION") -private fun apertureBrandPlayPath() = Path().apply { - moveTo(45.2f, 38.5f) - lineTo(62.1f, 47.7f) - quadraticBezierTo(66.2f, 50f, 62.1f, 52.3f) - lineTo(45.2f, 61.5f) - quadraticBezierTo(42f, 63.2f, 41.2f, 59.8f) - quadraticBezierTo(41f, 59.1f, 41f, 57.6f) - lineTo(41f, 42.4f) - quadraticBezierTo(41f, 40.9f, 41.2f, 40.2f) - quadraticBezierTo(42f, 36.8f, 45.2f, 38.5f) - close() -} - -private fun mixBrandColor(first: Color, second: Color, secondFraction: Float): Color = Color( - red = first.red + (second.red - first.red) * secondFraction, - green = first.green + (second.green - first.green) * secondFraction, - blue = first.blue + (second.blue - first.blue) * secondFraction, - alpha = first.alpha + (second.alpha - first.alpha) * secondFraction -) diff --git a/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMotion.kt b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMotion.kt new file mode 100644 index 00000000..0c1ed67f --- /dev/null +++ b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandMotion.kt @@ -0,0 +1,25 @@ +package me.xdan.aperture.ui.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import me.xdan.aperture.ui.theme.tokens.ApertureMotion + +/** The original logo's quick, even turn. */ +internal const val BrandSpinDurationMillis = 650 +internal const val BrandSpinBounceDegrees = 4f +internal val BrandSpinEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) + +/** Original 650 ms spin, followed by a restrained Expressive spring settle. */ +internal suspend fun Animatable.animateBrandTurn(motion: ApertureMotion) { + val start = value % 360f + snapTo(start) + animateTo( + targetValue = start + 360f - BrandSpinBounceDegrees, + animationSpec = tween(BrandSpinDurationMillis, easing = BrandSpinEasing) + ) + // The final four degrees spring through their target, giving the turn a soft landing. + animateTo(start + 360f, motion.brandFastSpatial()) + snapTo(start) +} diff --git a/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandPalette.kt b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandPalette.kt new file mode 100644 index 00000000..e326f467 --- /dev/null +++ b/app/src/main/java/me/xdan/aperture/ui/component/ApertureBrandPalette.kt @@ -0,0 +1,54 @@ +package me.xdan.aperture.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.tv.material3.ColorScheme +import androidx.tv.material3.MaterialTheme +import com.google.android.material.color.MaterialColors +import me.xdan.aperture.ui.theme.ApertureTheme +import me.xdan.aperture.ui.theme.Background +import me.xdan.aperture.ui.theme.InversePrimary +import me.xdan.aperture.ui.theme.OnPrimary +import me.xdan.aperture.ui.theme.Primary +import me.xdan.aperture.ui.theme.inversePrimaryForAccent + +internal data class ApertureBrandPalette( + val blade: Color, + val overlap: Color, + val play: Color, + val background: Color +) + +private val AperturePurpleBrandPalette = ApertureBrandPalette( + blade = InversePrimary, + overlap = OnPrimary, + play = Primary, + background = Background +) + +internal fun apertureBrandPalette(scheme: ColorScheme, isMaterialTv: Boolean): ApertureBrandPalette = + if (isMaterialTv) AperturePurpleBrandPalette else ApertureBrandPalette( + blade = scheme.inversePrimary, + overlap = scheme.onPrimary, + play = scheme.primary, + background = scheme.background + ) + +/** Cinematic artwork owns this palette, independently of the app's selected theme. */ +internal fun artworkApertureBrandPalette(accent: Color): ApertureBrandPalette { + val roles = MaterialColors.getColorRoles(accent.copy(alpha = 1f).toArgb(), false) + return ApertureBrandPalette( + blade = inversePrimaryForAccent(accent), + overlap = Color(roles.onAccent), + play = Color(roles.accent), + background = Background + ) +} + +/** Resolve on every theme recomposition, including the animated Artwork Dynamic palette. */ +@Composable +internal fun currentApertureBrandPalette(): ApertureBrandPalette = apertureBrandPalette( + scheme = MaterialTheme.colorScheme, + isMaterialTv = ApertureTheme.isMaterialTv +) diff --git a/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientGlass.kt b/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientGlass.kt new file mode 100644 index 00000000..a3e0ac66 --- /dev/null +++ b/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientGlass.kt @@ -0,0 +1,95 @@ +package me.xdan.aperture.ui.screen.ambient + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +@Stable +internal class AmbientGlassBackdropState(val layer: GraphicsLayer) { + var origin by mutableStateOf(Offset.Zero) +} + +internal val LocalAmbientGlassBackdrop = staticCompositionLocalOf { null } + +@Composable +internal fun rememberAmbientGlassBackdrop(): AmbientGlassBackdropState { + val layer = rememberGraphicsLayer() + return remember(layer) { AmbientGlassBackdropState(layer) } +} + +/** Record artwork only: glass and foreground content must never feed back into this layer. */ +@Composable +internal fun AmbientGlassBackdrop( + state: AmbientGlassBackdropState, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier + .onGloballyPositioned { state.origin = it.positionInRoot() } + .drawWithContent { + state.layer.record { this@drawWithContent.drawContent() } + drawLayer(state.layer) + }, + content = content + ) +} + +/** Frost only the small card region; the logo, wordmark and clock stay in a separate sharp layer. */ +@Composable +internal fun AmbientGlassSurface( + accent: Color, + shape: Shape, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val backdrop = LocalAmbientGlassBackdrop.current + val radius = with(LocalDensity.current) { 32.dp.toPx() } + val blur = remember(radius) { BlurEffect(radius, radius, TileMode.Clamp) } + val canBlur = backdrop != null && blur.isSupported() + var origin by remember { mutableStateOf(Offset.Zero) } + val base = Color(0xFF111014) + val tint = lerp(base, accent, 0.06f).copy(alpha = if (canBlur) 0.68f else 0.88f) + Box(modifier = modifier.clip(shape)) { + if (canBlur && backdrop != null) { + Box( + Modifier + .matchParentSize() + .onGloballyPositioned { origin = it.positionInRoot() } + .graphicsLayer { renderEffect = blur } + .drawBehind { + val offset = backdrop.origin - origin + translate(offset.x, offset.y) { drawLayer(backdrop.layer) } + } + ) + } + Box(Modifier.matchParentSize().background(tint)) + content() + } +} diff --git a/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientMode.kt b/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientMode.kt index 208a971b..b5be125f 100644 --- a/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientMode.kt +++ b/app/src/main/java/me/xdan/aperture/ui/screen/ambient/AmbientMode.kt @@ -11,7 +11,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,9 +27,9 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -38,15 +37,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -65,6 +62,9 @@ import me.xdan.aperture.data.local.entity.MediaEntity import me.xdan.aperture.domain.model.AmbientBrandPlacement import me.xdan.aperture.domain.model.AmbientModeType import me.xdan.aperture.ui.component.ApertureBrandMark +import me.xdan.aperture.ui.component.ApertureBrandPalette +import me.xdan.aperture.ui.component.artworkApertureBrandPalette +import me.xdan.aperture.ui.component.currentApertureBrandPalette import me.xdan.aperture.ui.theme.ApertureBrandFontFamily import me.xdan.aperture.util.BackdropImageUsage import me.xdan.aperture.util.backdropImageSpec @@ -86,6 +86,7 @@ fun AmbientMode( val hostView = LocalView.current val allMedia by viewModel.media.collectAsState() val settings by viewModel.settings.collectAsState() + val glassBackdrop = rememberAmbientGlassBackdrop() // Ambient Mode is Aperture's own idle experience, so Android's native // dream/screensaver must not replace it while the mode is visible. Restore @@ -96,17 +97,21 @@ fun AmbientMode( onDispose { hostView.keepScreenOn = wasKeepingScreenOn } } - when (settings.mode) { - AmbientModeType.CINEMATIC -> CinematicAmbientMode( - media = remember(allMedia) { eligibleCinematicMedia(allMedia) }, - showClock = settings.showClock, - artworkAccent = viewModel::artworkAccent - ) - AmbientModeType.POSTER_WALL -> PosterWallAmbientMode( - media = remember(allMedia) { eligiblePosterMedia(allMedia) }, - brandPlacement = settings.wallBrandPlacement, - showClock = settings.showClock - ) + CompositionLocalProvider(LocalAmbientGlassBackdrop provides glassBackdrop) { + when (settings.mode) { + AmbientModeType.CINEMATIC -> CinematicAmbientMode( + media = remember(allMedia) { eligibleCinematicMedia(allMedia) }, + showClock = settings.showClock, + glassBackdrop = glassBackdrop, + artworkAccent = viewModel::artworkAccent + ) + AmbientModeType.POSTER_WALL -> PosterWallAmbientMode( + media = remember(allMedia) { eligiblePosterMedia(allMedia) }, + brandPlacement = settings.wallBrandPlacement, + showClock = settings.showClock, + glassBackdrop = glassBackdrop + ) + } } } @@ -114,25 +119,24 @@ fun AmbientMode( private fun CinematicAmbientMode( media: List, showClock: Boolean, + glassBackdrop: AmbientGlassBackdropState, artworkAccent: suspend (String) -> Int? ) { - val themeAccent = MaterialTheme.colorScheme.primary - val currentThemeAccent by rememberUpdatedState(themeAccent) + val themePalette = currentApertureBrandPalette() val mediaKey = remember(media) { media.joinToString("|") { "${it.id}:${it.backdropPath}" } } var current by remember(mediaKey) { mutableStateOf(media.randomOrNull()) } - var logoAccent by remember(mediaKey) { mutableStateOf(themeAccent) } + var logoAccent by remember(mediaKey) { mutableStateOf(null) } var transitionKey by remember(mediaKey) { mutableIntStateOf(0) } val artworkAlpha = remember(mediaKey) { Animatable(0f) } - val animatedLogoAccent by animateColorAsState( - targetValue = logoAccent, - animationSpec = tween(CinematicTransitionMillis, easing = FastOutSlowInEasing), - label = "ambientArtworkAccent" - ) + val targetPalette = remember(logoAccent, themePalette) { + logoAccent?.let(::artworkApertureBrandPalette) ?: themePalette + } + val palette = animatedAmbientPalette(targetPalette) LaunchedEffect(mediaKey) { artworkAlpha.snapTo(0f) current?.backdropPath?.let { path -> - logoAccent = artworkAccent(path)?.let { Color(it) } ?: currentThemeAccent + logoAccent = artworkAccent(path)?.let { Color(it) } } if (current != null) { artworkAlpha.animateTo( @@ -146,7 +150,6 @@ private fun CinematicAmbientMode( val nextAccent = next.backdropPath ?.let { artworkAccent(it) } ?.let { Color(it) } - ?: currentThemeAccent delay(CinematicHoldMillis) artworkAlpha.animateTo( 0f, @@ -167,30 +170,25 @@ private fun CinematicAmbientMode( .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - current?.let { item -> - CinematicArtwork( - media = item, - modifier = Modifier.graphicsLayer { alpha = artworkAlpha.value } - ) - } + AmbientGlassBackdrop(glassBackdrop, Modifier.fillMaxSize()) { + current?.let { item -> + CinematicArtwork( + media = item, + modifier = Modifier.graphicsLayer { alpha = artworkAlpha.value } + ) + } - if (current == null) AmbientArtworkEmptyState() + if (current == null) AmbientArtworkEmptyState() + } - BrandChip( - accent = animatedLogoAccent, + AmbientBrandCluster( + palette = palette, spinKey = transitionKey, + showClock = showClock, modifier = Modifier .align(Alignment.TopStart) .padding(start = 38.dp, top = 34.dp) ) - if (showClock) { - AmbientClock( - accent = animatedLogoAccent, - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 38.dp, top = 108.dp) - ) - } } } @@ -267,10 +265,12 @@ private fun CinematicArtwork( private fun PosterWallAmbientMode( media: List, brandPlacement: AmbientBrandPlacement, - showClock: Boolean + showClock: Boolean, + glassBackdrop: AmbientGlassBackdropState ) { val background = MaterialTheme.colorScheme.background - val accent = MaterialTheme.colorScheme.primary + val palette = currentApertureBrandPalette() + val accent = palette.play val mediaKey = remember(media) { media.joinToString("|") { "${it.id}:${it.posterPath}" } } var spinKey by remember(mediaKey, brandPlacement) { mutableIntStateOf(0) } @@ -286,86 +286,80 @@ private fun PosterWallAmbientMode( .fillMaxSize() .background(background) ) { - if (media.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 26.dp, vertical = 22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - repeat(PosterRows) { row -> - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - horizontalArrangement = Arrangement.spacedBy(18.dp) - ) { - repeat(PosterColumns) { column -> - val index = row * PosterColumns + column - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - contentAlignment = Alignment.Center - ) { - AmbientPosterSlot( - media = media, - mediaKey = mediaKey, - index = index, + AmbientGlassBackdrop(glassBackdrop, Modifier.fillMaxSize().background(background)) { + if (media.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 26.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + repeat(PosterRows) { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + repeat(PosterColumns) { column -> + val index = row * PosterColumns + column + Box( modifier = Modifier - .fillMaxHeight() - .aspectRatio(2f / 3f) - ) + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + AmbientPosterSlot( + media = media, + mediaKey = mediaKey, + index = index, + modifier = Modifier + .fillMaxHeight() + .aspectRatio(2f / 3f) + ) + } } } } } + } else { + AmbientArtworkEmptyState() } - } else { - AmbientArtworkEmptyState() } when (brandPlacement) { - AmbientBrandPlacement.TOP_LEFT -> BrandChip( - accent = accent, + AmbientBrandPlacement.TOP_LEFT -> AmbientBrandCluster( + palette = palette, spinKey = spinKey, + showClock = showClock, modifier = Modifier .align(Alignment.TopStart) .padding(start = 38.dp, top = 34.dp) ) - AmbientBrandPlacement.CENTRE -> Box( + AmbientBrandPlacement.CENTRE -> AmbientGlassSurface( + accent = accent, + shape = RoundedCornerShape(72.dp), modifier = Modifier .align(Alignment.Center) - .size(272.dp) - .background(background, CircleShape), - contentAlignment = Alignment.Center + .size(248.dp) ) { - Box( - Modifier - .size(210.dp) - .blur(28.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) - .background(accent.copy(alpha = 0.18f), CircleShape) - ) - ApertureBrandMark( - modifier = Modifier.size(176.dp), - accent = accent, - spinBlades = spinKey > 0, - spinKey = spinKey - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ApertureBrandMark( + modifier = Modifier.size(176.dp), + spinBlades = spinKey > 0, + spinKey = spinKey, + palette = palette + ) + } } } - if (showClock) { + if (showClock && brandPlacement == AmbientBrandPlacement.CENTRE) { AmbientClock( accent = accent, - modifier = when (brandPlacement) { - AmbientBrandPlacement.TOP_LEFT -> Modifier - .align(Alignment.TopStart) - .padding(start = 38.dp, top = 108.dp) - AmbientBrandPlacement.CENTRE -> Modifier - .align(Alignment.Center) - .offset(y = 164.dp) - } + modifier = Modifier + .align(Alignment.Center) + .offset(y = 164.dp) ) } } @@ -427,22 +421,36 @@ private fun AmbientPosterSlot( } } +@Composable +private fun AmbientBrandCluster( + palette: ApertureBrandPalette, + spinKey: Int, + showClock: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + BrandChip(palette = palette, spinKey = spinKey) + if (showClock) AmbientClock(accent = palette.play) + } +} + @Composable private fun BrandChip( - accent: Color, + palette: ApertureBrandPalette, spinKey: Int, modifier: Modifier = Modifier ) { - AmbientGlassChip( - accent = accent, - contentPadding = PaddingValues(start = 12.dp, top = 8.dp, end = 22.dp, bottom = 8.dp), + AmbientBrandChip( + accent = palette.play, + shape = RoundedCornerShape(28.dp), + contentPadding = PaddingValues(start = 14.dp, top = 10.dp, end = 24.dp, bottom = 10.dp), modifier = modifier ) { ApertureBrandMark( modifier = Modifier.size(46.dp), - accent = accent, spinBlades = spinKey > 0, - spinKey = spinKey + spinKey = spinKey, + palette = palette ) Spacer(Modifier.width(9.dp)) Text( @@ -450,7 +458,7 @@ private fun BrandChip( style = MaterialTheme.typography.headlineSmall.copy( fontFamily = ApertureBrandFontFamily ), - color = accent + color = palette.play ) } } @@ -468,9 +476,10 @@ private fun AmbientClock( delay(30_000L) } } - AmbientGlassChip( + AmbientBrandChip( accent = accent, - contentPadding = PaddingValues(horizontal = 19.dp, vertical = 9.dp), + shape = RoundedCornerShape(24.dp), + contentPadding = PaddingValues(horizontal = 21.dp, vertical = 10.dp), modifier = modifier ) { Text( @@ -482,38 +491,32 @@ private fun AmbientClock( } @Composable -private fun AmbientGlassChip( +private fun AmbientBrandChip( accent: Color, + shape: Shape, contentPadding: PaddingValues, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { - Box(modifier = modifier) { - Box( - modifier = Modifier - .matchParentSize() - .blur(22.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) - .background(accent.copy(alpha = 0.32f), CircleShape) - ) + AmbientGlassSurface(accent = accent, shape = shape, modifier = modifier) { Row( - modifier = Modifier - .background( - Brush.horizontalGradient( - listOf( - accent.copy(alpha = 0.28f), - Color.Black.copy(alpha = 0.48f) - ) - ), - CircleShape - ) - .border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape) - .padding(contentPadding), + modifier = Modifier.padding(contentPadding), verticalAlignment = Alignment.CenterVertically, content = content ) } } +/** Build tonal roles once per title, then animate each role without recalculating HCT per frame. */ +@Composable +private fun animatedAmbientPalette(target: ApertureBrandPalette): ApertureBrandPalette { + val spec = tween(CinematicTransitionMillis, easing = FastOutSlowInEasing) + val blade by animateColorAsState(target.blade, spec, label = "ambientBlade") + val overlap by animateColorAsState(target.overlap, spec, label = "ambientOverlap") + val play by animateColorAsState(target.play, spec, label = "ambientPlay") + return target.copy(blade = blade, overlap = overlap, play = play) +} + @Composable private fun AmbientArtworkEmptyState() { Column( @@ -523,7 +526,6 @@ private fun AmbientArtworkEmptyState() { ) { ApertureBrandMark( modifier = Modifier.size(120.dp), - accent = MaterialTheme.colorScheme.primary ) Spacer(Modifier.height(18.dp)) Text( diff --git a/app/src/main/java/me/xdan/aperture/ui/screen/launch/ApertureLaunchAnimation.kt b/app/src/main/java/me/xdan/aperture/ui/screen/launch/ApertureLaunchAnimation.kt index 263c951b..c0a66ed8 100644 --- a/app/src/main/java/me/xdan/aperture/ui/screen/launch/ApertureLaunchAnimation.kt +++ b/app/src/main/java/me/xdan/aperture/ui/screen/launch/ApertureLaunchAnimation.kt @@ -1,9 +1,6 @@ package me.xdan.aperture.ui.screen.launch import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -11,209 +8,74 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.rotate -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import kotlin.math.pow - -private const val AnimationDurationMillis = 2_150 - -/** - * Builds the Aperture mark from its individual shutter blades before revealing the play icon. - * The paths use the same 100 x 100 coordinates as the source SVG. - */ +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.xdan.aperture.ui.component.ApertureBrandArtwork +import me.xdan.aperture.ui.component.currentApertureBrandPalette +import me.xdan.aperture.ui.component.ApertureBrandPose +import me.xdan.aperture.ui.component.animateBrandTurn +import me.xdan.aperture.ui.theme.ApertureTheme + +/** Circle → soft square → six-sided cookie → six blades, followed by a springing play symbol. */ @Composable -fun ApertureLaunchAnimation( - onFinished: () -> Unit, - modifier: Modifier = Modifier -) { - val timeline = remember { Animatable(0f) } - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.background - +fun ApertureLaunchAnimation(onFinished: () -> Unit, modifier: Modifier = Modifier) { + val colors = currentApertureBrandPalette() + val motion = ApertureTheme.motion + val finished = rememberUpdatedState(onFinished) + val seedShape = remember { Animatable(0f) } + val assembly = remember { Animatable(0f) } + val rotation = remember { Animatable(0f) } + val scale = remember { Animatable(0.65f) } + val overlaps = remember { Animatable(0f) } + val playScale = remember { Animatable(0f) } + val playAlpha = remember { Animatable(0f) } LaunchedEffect(Unit) { - timeline.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = AnimationDurationMillis, - easing = LinearEasing - ) - ) - onFinished() + coroutineScope { + launch { rotation.animateBrandTurn(motion) } + launch { + scale.animateTo(1f, motion.brandFastSpatial()) + seedShape.animateTo(1f, motion.brandFastSpatial()) + seedShape.animateTo(2f, motion.brandFastSpatial()) + } + } + coroutineScope { + launch { assembly.animateTo(1f, motion.brandSlowSpatial()) } + launch { + snapshotFlow { assembly.value }.first { it >= 0.7f } + overlaps.animateTo(1f, motion.brandEffects()) + } + launch { + snapshotFlow { assembly.value }.first { it >= 0.9f } + coroutineScope { + launch { playAlpha.animateTo(1f, motion.brandFastEffects()) } + playScale.animateTo(1f, motion.brandFastSpatial()) + } + } + } + // Completion follows the springs. System animator duration scaling applies, + // including finishing immediately when animations are disabled. + finished.value() } - Box( - modifier = modifier - .fillMaxSize() - .background(background), + modifier.fillMaxSize().background(colors.background), contentAlignment = Alignment.Center ) { - ApertureAnimatedMark( - progress = timeline.value, - primary = primary, - modifier = Modifier.size(224.dp) - ) - } -} - -@Composable -private fun ApertureAnimatedMark( - progress: Float, - primary: Color, - modifier: Modifier = Modifier -) { - val bladePath = remember { apertureBladePath() } - val playPath = remember { aperturePlayPath() } - - val bladeBrush = remember(primary) { - Brush.linearGradient( - colorStops = arrayOf( - 0f to Color.White.copy(alpha = 0.32f), - 0.24f to primary.copy(alpha = 0.58f), - 0.72f to primary.copy(alpha = 0.19f), - 1f to Color.White.copy(alpha = 0.07f) - ), - start = Offset(35f, 18f), - end = Offset(76f, 67f) - ) - } - val bladeStroke = mix(primary, Color.White, 0.46f) - - // Once the shutter is assembled, pull every blade into the centre and compress it. - // It then springs past its resting size before settling as the play icon appears. - val inward = when { - progress < 0.48f -> 0f - progress < 0.65f -> lerp(0f, 7.2f, easeInOut(normalise(progress, 0.48f, 0.65f))) - progress < 0.76f -> 7.2f - progress < 0.88f -> lerp(7.2f, 0f, easeOutCubic(normalise(progress, 0.76f, 0.88f))) - else -> 0f - } - val markScale = when { - progress < 0.63f -> 1f - progress < 0.72f -> lerp(1f, 0.87f, easeInOut(normalise(progress, 0.63f, 0.72f))) - progress < 0.84f -> lerp(0.87f, 1.055f, easeOutCubic(normalise(progress, 0.72f, 0.84f))) - progress < 0.94f -> lerp(1.055f, 1f, easeOutCubic(normalise(progress, 0.84f, 0.94f))) - else -> 1f - } - val playProgress = easeOutCubic(normalise(progress, 0.76f, 0.91f)) - val playScale = when { - progress < 0.76f -> 0f - progress < 0.87f -> lerp(0f, 1.18f, easeOutCubic(normalise(progress, 0.76f, 0.87f))) - progress < 0.96f -> lerp(1.18f, 1f, easeOutCubic(normalise(progress, 0.87f, 0.96f))) - else -> 1f - } - - Canvas(modifier = modifier) { - val unit = size.minDimension / 100f - - scale(scale = markScale, pivot = center) { - scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) { - repeat(6) { index -> - // Leave the first blade on its own briefly, then assemble clockwise. - val revealStart = if (index == 0) 0f else 0.105f + (index - 1) * 0.064f - val reveal = easeOutCubic(normalise(progress, revealStart, revealStart + 0.145f)) - val rotation = index * 60f - (1f - reveal) * 34f - val bladeScale = lerp(0.46f, 1f, reveal) - val revealOffset = lerp(9f, 0f, reveal) - - rotate(degrees = rotation, pivot = LogoCentre) { - translate(top = inward + revealOffset) { - scale(scale = bladeScale, pivot = LogoCentre) { - // A restrained local shadow gives the SVG's glass elevation without - // rasterising the logo or requiring a blur effect on every frame. - translate(top = 1.1f) { - drawPath( - path = bladePath, - color = Color.Black, - alpha = 0.24f * reveal - ) - } - drawPath( - path = bladePath, - brush = bladeBrush, - alpha = 0.84f * reveal, - blendMode = BlendMode.Screen - ) - drawPath( - path = bladePath, - color = bladeStroke, - alpha = 0.9f * reveal, - style = Stroke(width = 1.15f, join = StrokeJoin.Round) - ) - } - } - } - } - - if (playScale > 0f) { - scale(scale = playScale, pivot = LogoCentre) { - drawPath( - path = playPath, - color = Color.White, - alpha = playProgress - ) - } - } - } + ApertureBrandArtwork(Modifier.size(224.dp)) { + ApertureBrandPose( + seedShape = seedShape.value, + assembly = assembly.value, + rotation = rotation.value, + scale = scale.value, + overlapAlpha = overlaps.value, + playScale = playScale.value, + playAlpha = playAlpha.value + ) } } } - -private val LogoCentre = Offset(50f, 50f) - -@Suppress("DEPRECATION") -private fun apertureBladePath() = Path().apply { - moveTo(54f, 20f) - lineTo(85.2f, 20f) - quadraticBezierTo(88.5f, 20f, 89.7f, 22.5f) - quadraticBezierTo(90.5f, 24.3f, 88.1f, 26.7f) - lineTo(68.7f, 43.1f) - quadraticBezierTo(65f, 46.8f, 61.3f, 43.1f) - lineTo(48.1f, 29.9f) - quadraticBezierTo(44.3f, 26.1f, 47f, 22.3f) - quadraticBezierTo(49f, 20f, 54f, 20f) - close() -} - -@Suppress("DEPRECATION") -private fun aperturePlayPath() = Path().apply { - moveTo(45.2f, 38.5f) - lineTo(62.1f, 47.7f) - quadraticBezierTo(66.2f, 50f, 62.1f, 52.3f) - lineTo(45.2f, 61.5f) - quadraticBezierTo(42f, 63.2f, 41.2f, 59.8f) - quadraticBezierTo(41f, 59.1f, 41f, 57.6f) - lineTo(41f, 42.4f) - quadraticBezierTo(41f, 40.9f, 41.2f, 40.2f) - quadraticBezierTo(42f, 36.8f, 45.2f, 38.5f) - close() -} - -private fun normalise(value: Float, start: Float, end: Float): Float = - ((value - start) / (end - start)).coerceIn(0f, 1f) - -private fun lerp(start: Float, end: Float, fraction: Float): Float = - start + (end - start) * fraction - -private fun easeOutCubic(value: Float): Float = 1f - (1f - value).pow(3) - -private fun easeInOut(value: Float): Float = - if (value < 0.5f) 4f * value.pow(3) else 1f - (-2f * value + 2f).pow(3) / 2f - -private fun mix(first: Color, second: Color, secondFraction: Float): Color = Color( - red = lerp(first.red, second.red, secondFraction), - green = lerp(first.green, second.green, secondFraction), - blue = lerp(first.blue, second.blue, secondFraction), - alpha = lerp(first.alpha, second.alpha, secondFraction) -) diff --git a/app/src/main/java/me/xdan/aperture/ui/theme/Color.kt b/app/src/main/java/me/xdan/aperture/ui/theme/Color.kt index f4ac6172..c36da23f 100644 --- a/app/src/main/java/me/xdan/aperture/ui/theme/Color.kt +++ b/app/src/main/java/me/xdan/aperture/ui/theme/Color.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color // Vibrant Material 3 Palette val Primary = Color(0xFFD0BCFF) +val InversePrimary = Color(0xFF6750A4) val OnPrimary = Color(0xFF381E72) val PrimaryContainer = Color(0xFF4F378B) val OnPrimaryContainer = Color(0xFFEADDFF) diff --git a/app/src/main/java/me/xdan/aperture/ui/theme/Theme.kt b/app/src/main/java/me/xdan/aperture/ui/theme/Theme.kt index 2b70980a..ae50c3e9 100644 --- a/app/src/main/java/me/xdan/aperture/ui/theme/Theme.kt +++ b/app/src/main/java/me/xdan/aperture/ui/theme/Theme.kt @@ -36,6 +36,7 @@ val ApertureThemeOptions = listOf( private val DarkColorScheme = darkColorScheme( primary = Primary, + inversePrimary = InversePrimary, onPrimary = OnPrimary, primaryContainer = PrimaryContainer, onPrimaryContainer = OnPrimaryContainer, @@ -74,6 +75,12 @@ fun ApertureTheme( animationSpec = tween(durationMillis = 520), label = "apertureThemeAccent" ).value + val targetInversePrimary = remember(accent) { inversePrimaryForAccent(accent) } + val animatedInversePrimary = animateColorAsState( + targetValue = targetInversePrimary, + animationSpec = tween(durationMillis = 520), + label = "apertureThemeInversePrimary" + ).value val animatedSecondary = animateColorAsState( targetValue = secondary, animationSpec = tween(durationMillis = 520), @@ -85,30 +92,13 @@ fun ApertureTheme( label = "apertureThemeTertiary" ).value - val scheme = if (themeId == "purple") { - DarkColorScheme - } else { - darkColorScheme( - primary = animatedAccent, - onPrimary = if (themeId == "classic") Color(0xFF1A191C) else Color(0xFF151218), - primaryContainer = if (themeId == "classic") Color(0xFFE7E2E8) else animatedAccent.copy(alpha = 0.30f), - onPrimaryContainer = if (themeId == "classic") Color(0xFF454047) else animatedAccent, - secondary = animatedSecondary, - onSecondary = Color(0xFF151218), - secondaryContainer = if (themeId == "classic") Color(0xFFDCD7DE) else animatedSecondary.copy(alpha = 0.20f), - onSecondaryContainer = if (themeId == "classic") Color(0xFF464047) else animatedSecondary, - tertiary = animatedTertiary, - onTertiary = Color(0xFF20151A), - tertiaryContainer = if (themeId == "classic") Color(0xFFD8D2D9) else animatedTertiary.copy(alpha = 0.20f), - onTertiaryContainer = if (themeId == "classic") Color(0xFF443D45) else animatedTertiary, - background = Color(0xFF111014), - onBackground = Color(0xFFF1ECF2), - surface = Color(0xFF111014), - onSurface = Color(0xFFF1ECF2), - surfaceVariant = Color(0xFF252229), - onSurfaceVariant = Color(0xFFE6E0E8) - ) - } + val scheme = apertureDarkColorScheme( + themeId = themeId, + primary = animatedAccent, + inversePrimary = animatedInversePrimary, + secondary = animatedSecondary, + tertiary = animatedTertiary + ) val apertureColorScheme = remember { scheme.toApertureColorScheme() @@ -221,3 +211,38 @@ private fun ColorScheme.toApertureColorScheme(): ApertureColorScheme { borderVariant = borderVariant ) } + +/** Builds the TV Material roles without a composition, including each accent's inverse role. */ +internal fun apertureDarkColorScheme( + themeId: String, + primary: Color, + inversePrimary: Color, + secondary: Color, + tertiary: Color +): ColorScheme { + return if (themeId == "purple") { + DarkColorScheme + } else { + darkColorScheme( + primary = primary, + inversePrimary = inversePrimary, + onPrimary = if (themeId == "classic") Color(0xFF1A191C) else Color(0xFF151218), + primaryContainer = if (themeId == "classic") Color(0xFFE7E2E8) else primary.copy(alpha = 0.30f), + onPrimaryContainer = if (themeId == "classic") Color(0xFF454047) else primary, + secondary = secondary, + onSecondary = Color(0xFF151218), + secondaryContainer = if (themeId == "classic") Color(0xFFDCD7DE) else secondary.copy(alpha = 0.20f), + onSecondaryContainer = if (themeId == "classic") Color(0xFF464047) else secondary, + tertiary = tertiary, + onTertiary = Color(0xFF20151A), + tertiaryContainer = if (themeId == "classic") Color(0xFFD8D2D9) else tertiary.copy(alpha = 0.20f), + onTertiaryContainer = if (themeId == "classic") Color(0xFF443D45) else tertiary, + background = Color(0xFF111014), + onBackground = Color(0xFFF1ECF2), + surface = Color(0xFF111014), + onSurface = Color(0xFFF1ECF2), + surfaceVariant = Color(0xFF252229), + onSurfaceVariant = Color(0xFFE6E0E8) + ) + } +} diff --git a/app/src/main/java/me/xdan/aperture/ui/theme/ThemeColorRoles.kt b/app/src/main/java/me/xdan/aperture/ui/theme/ThemeColorRoles.kt new file mode 100644 index 00000000..fdd39c13 --- /dev/null +++ b/app/src/main/java/me/xdan/aperture/ui/theme/ThemeColorRoles.kt @@ -0,0 +1,10 @@ +package me.xdan.aperture.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.google.android.material.color.MaterialColors + +/** In a dark Material scheme, Inverse Primary uses tone 40 of the primary tonal palette. */ +internal fun inversePrimaryForAccent(accent: Color): Color = Color( + MaterialColors.getColorRoles(accent.copy(alpha = 1f).toArgb(), true).accent +) diff --git a/app/src/main/java/me/xdan/aperture/ui/theme/tokens/Motion.kt b/app/src/main/java/me/xdan/aperture/ui/theme/tokens/Motion.kt index 24985b40..f6310f00 100644 --- a/app/src/main/java/me/xdan/aperture/ui/theme/tokens/Motion.kt +++ b/app/src/main/java/me/xdan/aperture/ui/theme/tokens/Motion.kt @@ -7,6 +7,8 @@ import androidx.compose.animation.core.Easing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MotionScheme /** * Semantic motion tokens for Aperture. @@ -14,6 +16,7 @@ import androidx.compose.animation.core.tween * Motion in Aperture uses expressive easing and springs for spatial/focus * interactions, while playback and utility transitions stay precise. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) class ApertureMotion( private val focusStiffness: Float = 400f, private val focusDamping: Float = 0.75f, @@ -30,6 +33,17 @@ class ApertureMotion( val expressiveLoadingRotationDuration: Int = 2500, val expressiveLoadingMorphRotation: Float = 180f ) { + private val brandScheme = MotionScheme.expressive() + + /** Official M3 Expressive spatial tokens: rotation, geometry and size may overshoot. */ + fun brandSpatial(): FiniteAnimationSpec = brandScheme.defaultSpatialSpec() + fun brandFastSpatial(): FiniteAnimationSpec = brandScheme.fastSpatialSpec() + fun brandSlowSpatial(): FiniteAnimationSpec = brandScheme.slowSpatialSpec() + + /** Official effects tokens settle opacity without spatial bounce. */ + fun brandEffects(): FiniteAnimationSpec = brandScheme.defaultEffectsSpec() + fun brandFastEffects(): FiniteAnimationSpec = brandScheme.fastEffectsSpec() + /** Emphasized M3 easing: quick movement followed by a confident settle. */ val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f) diff --git a/app/src/main/res/drawable/ic_banner.png b/app/src/main/res/drawable/ic_banner.png index dc90d00d..f29a163e 100644 Binary files a/app/src/main/res/drawable/ic_banner.png and b/app/src/main/res/drawable/ic_banner.png differ diff --git a/app/src/main/res/drawable/ic_banner_foreground.png b/app/src/main/res/drawable/ic_banner_foreground.png index b40730b0..f6af4973 100644 Binary files a/app/src/main/res/drawable/ic_banner_foreground.png and b/app/src/main/res/drawable/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml index cf3108b3..6bbaa3f3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -1,5 +1,5 @@ - + - \ No newline at end of file + diff --git a/app/src/main/res/mipmap-hdpi/ic_banner.png b/app/src/main/res/mipmap-hdpi/ic_banner.png new file mode 100644 index 00000000..3b662883 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_banner_background_art.png b/app/src/main/res/mipmap-hdpi/ic_banner_background_art.png new file mode 100644 index 00000000..3c96c4fa Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_banner_background_art.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-hdpi/ic_banner_foreground.png new file mode 100644 index 00000000..6b44dca3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index bcd6935f..6a171ed6 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index c8f4f6db..7160bcc5 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index 56d4f855..feb83f8d 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 8d364bd5..b377afbc 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_banner.png b/app/src/main/res/mipmap-mdpi/ic_banner.png new file mode 100644 index 00000000..29560b39 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_banner_background_art.png b/app/src/main/res/mipmap-mdpi/ic_banner_background_art.png new file mode 100644 index 00000000..740e3316 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_banner_background_art.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-mdpi/ic_banner_foreground.png new file mode 100644 index 00000000..7aa4c181 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 1dbf0df2..bd0cb4ed 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index a6cc4265..f5df4f53 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp index 29c85738..d064ed97 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 00e35e8b..dffdb5bc 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.png b/app/src/main/res/mipmap-xhdpi/ic_banner.png index 5ee85ad9..36887ecd 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner.png and b/app/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner_background_art.png b/app/src/main/res/mipmap-xhdpi/ic_banner_background_art.png new file mode 100644 index 00000000..540bfd4e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_banner_background_art.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png index 8368ddc4..2fd9148a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index b9c71181..fbd130c8 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 759854c9..e0816ffc 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index c803d86b..2288a203 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 13b439f5..85b83a2d 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_banner.png b/app/src/main/res/mipmap-xxhdpi/ic_banner.png new file mode 100644 index 00000000..d601e533 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_banner_background_art.png b/app/src/main/res/mipmap-xxhdpi/ic_banner_background_art.png new file mode 100644 index 00000000..5337b28e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_banner_background_art.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_banner_foreground.png new file mode 100644 index 00000000..558947a2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index ec59b9fa..339bf714 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 2dc0da31..a81177ac 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index 7aa64a28..a660847f 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 53200988..34f3c9e1 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_banner.png b/app/src/main/res/mipmap-xxxhdpi/ic_banner.png new file mode 100644 index 00000000..a9bc66ea Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_banner_background_art.png b/app/src/main/res/mipmap-xxxhdpi/ic_banner_background_art.png new file mode 100644 index 00000000..bf9c05c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_banner_background_art.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_banner_foreground.png new file mode 100644 index 00000000..1f7c584c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index ce7e810d..7db3d8f3 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index bedae526..123eb651 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index c8f1435e..2fc5c90d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 416066aa..e8e0d07c 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/main/res/values/ic_banner_background.xml deleted file mode 100644 index 628f2cb6..00000000 --- a/app/src/main/res/values/ic_banner_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #0F1110 - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index 0740a267..ca1ac169 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #3E3C49 - \ No newline at end of file + #111014 + diff --git a/app/src/test/java/me/xdan/aperture/ui/component/ApertureBrandThemeTest.kt b/app/src/test/java/me/xdan/aperture/ui/component/ApertureBrandThemeTest.kt new file mode 100644 index 00000000..d99f677c --- /dev/null +++ b/app/src/test/java/me/xdan/aperture/ui/component/ApertureBrandThemeTest.kt @@ -0,0 +1,87 @@ +package me.xdan.aperture.ui.component + +import androidx.compose.ui.graphics.Color +import me.xdan.aperture.ui.theme.ApertureThemeOption +import me.xdan.aperture.ui.theme.ApertureThemeOptions +import me.xdan.aperture.ui.theme.InversePrimary +import me.xdan.aperture.ui.theme.OnPrimary +import me.xdan.aperture.ui.theme.Primary +import me.xdan.aperture.ui.theme.apertureDarkColorScheme +import me.xdan.aperture.ui.theme.inversePrimaryForAccent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import kotlin.math.pow + +class ApertureBrandThemeTest { + @Test + fun everyColouredThemeUsesItsOwnMaterialRoles() { + ApertureThemeOptions.filter { it.id != "classic" }.forEach { option -> + val scheme = scheme(option) + val palette = apertureBrandPalette(scheme, isMaterialTv = false) + assertEquals(option.label, scheme.inversePrimary, palette.blade) + assertEquals(option.label, scheme.onPrimary, palette.overlap) + assertEquals(option.label, scheme.primary, palette.play) + if (option.id != "purple") { + // Regression: darkColorScheme used to silently supply the purple default here. + assertNotEquals(option.label, InversePrimary, palette.blade) + assertEquals(option.label, 40.0, tone(palette.blade), 0.3) + } + } + } + + @Test + fun materialTvKeepsTheApprovedPurplePalette() { + val tv = ApertureThemeOptions.single { it.id == "classic" } + val scheme = scheme(tv) + val palette = apertureBrandPalette(scheme, isMaterialTv = true) + assertEquals(Color.White, scheme.primary) + assertEquals(InversePrimary, palette.blade) + assertEquals(OnPrimary, palette.overlap) + assertEquals(Primary, palette.play) + } + + @Test + fun artworkChangesUpdateBothTheBladeAndPlayColours() { + val dynamic = ApertureThemeOptions.single { it.id == "dynamic" } + val red = scheme(dynamic.copy(primary = Color(0xFFFF7474))) + val cyan = scheme(dynamic.copy(primary = Color(0xFF72D4EF))) + val first = apertureBrandPalette(red, isMaterialTv = false) + val next = apertureBrandPalette(cyan, isMaterialTv = false) + assertNotEquals(first.blade, next.blade) + assertNotEquals(first.play, next.play) + assertEquals(cyan.inversePrimary, next.blade) + assertEquals(cyan.onPrimary, next.overlap) + assertEquals(cyan.primary, next.play) + } + + @Test + fun cinematicArtworkSuppliesACompleteReadableTonalPalette() { + val red = artworkApertureBrandPalette(Color(0xFFDF7468)) + val cyan = artworkApertureBrandPalette(Color(0xFF54B8D2)) + assertNotEquals(red.blade, cyan.blade) + assertNotEquals(red.overlap, cyan.overlap) + assertNotEquals(red.play, cyan.play) + listOf(red, cyan).forEach { palette -> + assertEquals(40.0, tone(palette.blade), 0.3) + assertEquals(20.0, tone(palette.overlap), 0.3) + assertEquals(80.0, tone(palette.play), 0.3) + } + } + + private fun scheme(option: ApertureThemeOption) = apertureDarkColorScheme( + themeId = option.id, + primary = option.primary, + inversePrimary = inversePrimaryForAccent(option.primary), + secondary = option.secondary, + tertiary = option.tertiary + ) + + /** Independent L* calculation verifies that generated inverse colours use Material tone 40. */ + private fun tone(color: Color): Double { + fun linear(value: Float): Double = if (value <= 0.04045f) value / 12.92 + else ((value + 0.055) / 1.055).pow(2.4) + val y = 0.2126 * linear(color.red) + 0.7152 * linear(color.green) + 0.0722 * linear(color.blue) + return if (y <= 216.0 / 24389.0) y * 24389.0 / 27.0 else 116.0 * y.pow(1.0 / 3.0) - 16.0 + } +} diff --git a/artwork/README.md b/artwork/README.md new file mode 100644 index 00000000..d7ce89be --- /dev/null +++ b/artwork/README.md @@ -0,0 +1,82 @@ +# Aperture branding + +`aperture-logo.svg` is the approved vector master. Its six rounded blades and +solid intersections are also drawn by `ApertureBrandArtwork` in Compose. + +| Part | Aperture Purple role | Value | +| --- | --- | --- | +| Blades | Inverse Primary | `#6750A4` | +| Shared intersections | On Primary | `#381E72` | +| Play symbol and wordmark | Primary | `#D0BCFF` | +| Launcher and TV banner background | Background | `#111014` | + +In-app launch, sidebar and poster-wall marks read the active TV Material colour +scheme: Inverse Primary for blades, On Primary for intersections, and Primary +for the play symbol. Material TV deliberately retains the approved Aperture +Purple palette. Launcher assets, the wordmark and exported SVGs retain that same +purple brand identity. + +Each non-purple theme now supplies its own Inverse Primary, generated at Material +tone 40 through Google's public `MaterialColors.getColorRoles` API. This role +animates alongside Primary when switching themes or changing artwork accents. +The existing other theme roles are preserved. + +Cinematic Ambient Mode overrides the logo with the current backdrop's extracted +accent, independently of the app theme (including Material TV). Material tonal +roles keep the blades at tone 40, intersections at tone 20 and play/wordmark at +tone 80. All three colours crossfade with the title. Failed extraction falls +back to the selected theme's logo palette. Brand and clock cards have symmetric +rounded corners and a flat, dark surface with restrained artwork colour influence. +On Android 12 and newer, the actual artwork behind each card is frosted with a +32 dp blur; the foreground logo and text remain sharp. One artwork-only graphics +layer is shared by the cards, avoiding extra image requests or bitmap copies. +Older TVs use a stronger tint for legibility when native blur is unavailable. +The surface has no reflective rim or lighting gradient. + +The monochrome adaptive layer uses one white source colour with distinct alpha: +the play symbol is fully opaque, blades are 72% opaque and intersections are +32% opaque. Blade shapes are cut out under intersections, so the lower overlap +opacity stays intact when a launcher applies its Material You theme tint. +Adaptive foregrounds retain the existing safe-area padding; legacy square and +round icons are generated separately. The TV banner uses the app's own Outfit +SemiBold font, converted into vector paths in `aperture-banner.svg`. Its layered +purple-spectrum backdrop is defined in `aperture-banner-background.svg`; the +adaptive banner uses the same artwork as a separate background layer so Google +TV can sample the palette without baking it into the transparent logo layer. + +The adaptive banner foreground and background have their own expanded viewport +masters. Android expands adaptive child layers by 25% on every side; compensating +in the source keeps the complete visible artwork aligned inside the banner. +Both adaptive layers and flat fallbacks are exported for all five densities. The +onboarding drawable keeps its full-size artwork, separate from the padded +adaptive launcher layers. Run `python tools/check_brand_assets.py` to check every +density against Android's enlarged adaptive layers; the branding PR workflow +runs the same checks. + +Run `python tools/generate_brand_assets.py` from the repository root with Python, +Pillow, fontTools and Inkscape installed to regenerate every checked-in density, +legacy asset export, store image, README icon and TV banner. The old glass SVG +path is retained as a compatibility copy of the new mark. + +Launch morphs a circle into a soft square, rounds it back into a seed, then +unfolds it into the exact approved blade paths. Dark intersections are computed +from the current geometry; the play symbol emerges as the ring settles. +The sidebar turns the blade ring while the play symbol stays upright. Sidebar +and Ambient Mode restore the original 650 ms ease-in-out rotation. A small +four-degree overtravel then settles with the official fast spatial spring token. +The launch morph uses the same turn and spring landing while keeping its shape +transitions on the Expressive spatial tokens. + +Motion uses `MotionScheme.expressive()` through the brand methods in +`ApertureMotion`: fast/default/slow spatial specs for shape, scale and the spin +settle; +fast/default effects specs for opacity. Compose animator-duration scaling also +governs completion. + +References: +- https://m3.material.io/styles/motion/overview/how-it-works +- https://developer.android.com/reference/kotlin/androidx/compose/material3/MotionScheme + +Validation: regenerate and visually inspect assets; compile the standard debug +variant and run unit tests via the branding PR workflow. Review the launch and +rapid drawer reopen behaviour on a TV device before merging. diff --git a/artwork/aperture-banner-background-adaptive.svg b/artwork/aperture-banner-background-adaptive.svg new file mode 100644 index 00000000..99149ffd --- /dev/null +++ b/artwork/aperture-banner-background-adaptive.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/artwork/aperture-banner-background.svg b/artwork/aperture-banner-background.svg new file mode 100644 index 00000000..18c43730 --- /dev/null +++ b/artwork/aperture-banner-background.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/artwork/aperture-banner-foreground.svg b/artwork/aperture-banner-foreground.svg new file mode 100644 index 00000000..37a414f7 --- /dev/null +++ b/artwork/aperture-banner-foreground.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/artwork/aperture-banner.svg b/artwork/aperture-banner.svg new file mode 100644 index 00000000..ba1192cd --- /dev/null +++ b/artwork/aperture-banner.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/artwork/aperture-launcher-foreground.svg b/artwork/aperture-launcher-foreground.svg index 3974da10..c161f8b3 100644 --- a/artwork/aperture-launcher-foreground.svg +++ b/artwork/aperture-launcher-foreground.svg @@ -1,32 +1,32 @@ - - + + Aperture — Expressive + Six equal rounded shutter blades in Aperture Purple Inverse Primary #6750A4, with opaque On Primary #381E72 intersections and a foreground Primary #D0BCFF play symbol. The repeated flat intersections describe an interwoven ring rather than an opaque stack. Transparent background. Geometry preserved from the approved icon. - - - - - - - - - - - - - - - - + + + + + + - - - - - - - + + + + + + + + - + + + + + + + + + diff --git a/artwork/aperture-launcher-monochrome.svg b/artwork/aperture-launcher-monochrome.svg index 1436ce39..01627175 100644 --- a/artwork/aperture-launcher-monochrome.svg +++ b/artwork/aperture-launcher-monochrome.svg @@ -1,14 +1,40 @@ - - + + Aperture — Expressive + Aperture monochrome icon with a single white source colour: the play symbol is fully opaque, blades are 72 percent opaque, and intersections are 32 percent opaque. Blade fill is cut away beneath intersections so alpha levels remain distinct under theme tinting. - - - - - - - - + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + diff --git a/artwork/aperture-logo-glass.svg b/artwork/aperture-logo-glass.svg index 3208f836..caddb076 100644 --- a/artwork/aperture-logo-glass.svg +++ b/artwork/aperture-logo-glass.svg @@ -1,32 +1,33 @@ - + + Aperture — Expressive + Six equal rounded shutter blades in Aperture Purple Inverse Primary #6750A4, with opaque On Primary #381E72 intersections and a foreground Primary #D0BCFF play symbol. The repeated flat intersections describe an interwoven ring rather than an opaque stack. Transparent background. Geometry preserved from the approved icon. - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - + + + + + + + + + - - + + + + + + + + + + diff --git a/artwork/aperture-logo-monochrome.svg b/artwork/aperture-logo-monochrome.svg index a92f013a..1c40c0eb 100644 --- a/artwork/aperture-logo-monochrome.svg +++ b/artwork/aperture-logo-monochrome.svg @@ -1,15 +1,40 @@ - - + + Aperture — Expressive + Aperture monochrome icon with a single white source colour: the play symbol is fully opaque, blades are 72 percent opaque, and intersections are 32 percent opaque. Blade fill is cut away beneath intersections so alpha levels remain distinct under theme tinting. - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - + diff --git a/artwork/aperture-logo.svg b/artwork/aperture-logo.svg new file mode 100644 index 00000000..caddb076 --- /dev/null +++ b/artwork/aperture-logo.svg @@ -0,0 +1,33 @@ + + + Aperture — Expressive + Six equal rounded shutter blades in Aperture Purple Inverse Primary #6750A4, with opaque On Primary #381E72 intersections and a foreground Primary #D0BCFF play symbol. The repeated flat intersections describe an interwoven ring rather than an opaque stack. Transparent background. Geometry preserved from the approved icon. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7ac69ff..ad6a9cc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ moshiKotlin = "1.15.2" moshiKotlinCodegen = "1.15.2" datastorePreferences = "1.2.1" documentfile = "1.1.0" -material = "1.4.0" +material = "1.13.0" tvMaterial = "1.1.0" tvFoundation = "1.0.0" hilt = "2.60.1" diff --git a/tools/check_brand_assets.py b/tools/check_brand_assets.py new file mode 100644 index 00000000..7e69d70f --- /dev/null +++ b/tools/check_brand_assets.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Check TV banner sizes and Android's enlarged adaptive foreground viewport.""" +from pathlib import Path +import unittest + +from PIL import Image, ImageChops, ImageStat + +RES = Path(__file__).resolve().parents[1] / 'app/src/main/res' +SIZES = {'mdpi': (160, 90), 'hdpi': (240, 135), 'xhdpi': (320, 180), + 'xxhdpi': (480, 270), 'xxxhdpi': (640, 360)} + + +def ink_bounds(image): + return image.getchannel('A').point(lambda alpha: 255 if alpha >= 32 else 0).getbbox() + + +def adaptive_viewport(image): + # AdaptiveIconDrawable.getExtraInsetFraction() adds 25% on each side. + width, height = image.size + expanded = image.resize((round(width * 1.5), round(height * 1.5)), Image.Resampling.LANCZOS) + left, top = (expanded.width - width) // 2, (expanded.height - height) // 2 + return expanded.crop((left, top, left + width, top + height)) + + +class BannerAssetsTest(unittest.TestCase): + def test_each_density_preserves_the_complete_visible_banner(self): + full_artwork = Image.open(RES / 'drawable/ic_banner_foreground.png').convert('RGBA') + for density, size in SIZES.items(): + with self.subTest(density=density): + folder = RES / f'mipmap-{density}' + foreground = Image.open(folder / 'ic_banner_foreground.png').convert('RGBA') + background = Image.open(folder / 'ic_banner_background_art.png').convert('RGBA') + flat = Image.open(folder / 'ic_banner.png').convert('RGBA') + self.assertEqual(size, foreground.size) + self.assertEqual(size, background.size) + self.assertEqual(size, flat.size) + self.assertEqual((255, 255), background.getchannel('A').getextrema()) + self.assertEqual((255, 255), flat.getchannel('A').getextrema()) + bounds = ink_bounds(foreground) + self.assertIsNotNone(bounds) + # Content must fit the centre two-thirds before Android's enlargement. + self.assertGreaterEqual(bounds[0], size[0] / 6) + self.assertGreaterEqual(bounds[1], size[1] / 6) + self.assertLessEqual(bounds[2], size[0] * 5 / 6) + self.assertLessEqual(bounds[3], size[1] * 5 / 6) + expected = ink_bounds(full_artwork.resize(size, Image.Resampling.LANCZOS)) + actual = ink_bounds(adaptive_viewport(foreground)) + for wanted, rendered in zip(expected, actual): + self.assertAlmostEqual(wanted, rendered, delta=2) + # Leave visible margins after enlargement; neither end may touch the mask. + self.assertGreater(actual[0], size[0] * 0.06) + self.assertLess(actual[2], size[0] * 0.94) + # Background and padded foreground must reconstruct the flat banner + # after Android's 1.5x adaptive-layer enlargement and centre crop. + adaptive_bg = adaptive_viewport(background) + adaptive_fg = adaptive_viewport(foreground) + reconstructed = Image.alpha_composite(adaptive_bg, adaptive_fg).convert('RGB') + expected_banner = flat.convert('RGB') + mean_error = sum(ImageStat.Stat(ImageChops.difference( + reconstructed, expected_banner)).mean) / 3 + self.assertLess(mean_error, 4.0) + + def test_launcher_uses_the_padded_foreground(self): + import xml.etree.ElementTree as ET + root = ET.parse(RES / 'mipmap-anydpi-v26/ic_banner.xml').getroot() + self.assertEqual('@mipmap/ic_banner_foreground', root.find('foreground').get( + '{http://schemas.android.com/apk/res/android}drawable')) + self.assertEqual('@mipmap/ic_banner_background_art', root.find('background').get( + '{http://schemas.android.com/apk/res/android}drawable')) + + +class MonochromeIconTest(unittest.TestCase): + def test_each_density_keeps_three_alpha_levels_under_one_theme_tint(self): + assets = RES.parent / 'assets/res' + for density in SIZES: + with self.subTest(density=density): + png = Image.open(assets / f'mipmap-{density}/ic_launcher_monochrome.png').convert('RGBA') + webp = Image.open(RES / f'mipmap-{density}/ic_launcher_monochrome.webp').convert('RGBA') + for image in (png, webp): + for (x, y), expected_alpha in ( + ((50, 22), 82), # dark intersection + ((80, 24), 184), # blade body + ((50, 50), 255), # foreground play symbol + ): + px = round((x + 38) / 176 * image.width) + py = round((y + 38) / 176 * image.height) + red, green, blue, alpha = image.getpixel((px, py)) + self.assertEqual((255, 255, 255), (red, green, blue)) + self.assertAlmostEqual(expected_alpha, alpha, delta=12) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/generate_brand_assets.py b/tools/generate_brand_assets.py new file mode 100644 index 00000000..022639aa --- /dev/null +++ b/tools/generate_brand_assets.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Regenerate Aperture's launcher and TV branding. Requires Inkscape, Pillow, fontTools.""" +from copy import deepcopy +from pathlib import Path +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from PIL import Image, ImageDraw +from fontTools.ttLib import TTFont +from fontTools.pens.svgPathPen import SVGPathPen + +ROOT = Path(__file__).resolve().parents[1] +MAIN = ROOT / 'app/src/main' +ART = ROOT / 'artwork' +BACKGROUND = '#111014' +PRIMARY = '#D0BCFF' +MONO_PLAY = '#FFFFFF' +MONO_BLADE_OPACITY = 0.72 +MONO_OVERLAP_OPACITY = 0.32 +NS = 'http://www.w3.org/2000/svg' +ET.register_namespace('', NS) +SOURCE = ET.parse(ART / 'aperture-logo.svg').getroot() + + +def variant(viewbox='0 0 100 100', size=512, mono=False): + root = deepcopy(SOURCE) + root.set('viewBox', viewbox) + root.set('width', str(size)) + root.set('height', str(size)) + if mono: + defs = next(element for element in root if element.tag.endswith('defs')) + blades = next(element for element in root if element.get('id') == 'shutter-blades') + overlaps = next(element for element in root if element.get('id') == 'solid-intersections') + mask = ET.Element(f'{{{NS}}}mask', { + 'id': 'monochrome-blade-mask', + 'maskUnits': 'userSpaceOnUse', + 'x': '0', + 'y': '0', + 'width': '100', + 'height': '100', + }) + ET.SubElement(mask, f'{{{NS}}}rect', { + 'x': '0', 'y': '0', 'width': '100', 'height': '100', 'fill': '#FFFFFF' + }) + cutouts = deepcopy(overlaps) + cutouts.set('fill', '#000000') + for element in cutouts.iter(): + element.attrib.pop('id', None) + mask.append(cutouts) + defs.append(mask) + blades.set('mask', 'url(#monochrome-blade-mask)') + blades.set('opacity', str(MONO_BLADE_OPACITY)) + overlaps.set('fill', MONO_PLAY) + overlaps.set('opacity', str(MONO_OVERLAP_OPACITY)) + for element in root.iter(): + element_id = element.get('id') + if (element_id and element_id.startswith('blade-')) or element_id == 'play': + element.set('fill', MONO_PLAY) + elif element.tag.endswith('desc'): + element.text = ( + 'Aperture monochrome icon with a single white source colour: ' + 'the play symbol is fully opaque, blades are 72 percent opaque, ' + 'and intersections are 32 percent opaque. Blade fill is cut away ' + 'beneath intersections so alpha levels remain distinct under theme tinting.' + ) + return '\n'.join(line.rstrip() for line in ET.tostring(root, encoding='unicode').splitlines()) + + +def render(svg, size): + with tempfile.TemporaryDirectory() as directory: + src = Path(directory) / 'source.svg' + dst = Path(directory) / 'render.png' + src.write_text(svg) + subprocess.run(['inkscape', str(src), '--export-type=png', + f'--export-filename={dst}', f'--export-width={size}'], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return Image.open(dst).convert('RGBA') + + +def tile(mark, size, shape='rounded'): + result = Image.new('RGBA', (size, size)) + draw = ImageDraw.Draw(result) + bounds = (0, 0, size - 1, size - 1) + if shape == 'circle': + draw.ellipse(bounds, fill=BACKGROUND) + elif shape == 'square': + draw.rectangle(bounds, fill=BACKGROUND) + else: + draw.rounded_rectangle(bounds, radius=round(size * .22), fill=BACKGROUND) + dimension = round(size * .76) + scaled = mark.resize((dimension, dimension), Image.Resampling.LANCZOS) + result.alpha_composite(scaled, ((size - dimension) // 2, (size - dimension) // 2)) + return result + + +def save(image, path): + path.parent.mkdir(parents=True, exist_ok=True) + image.save(path, lossless=True) if path.suffix == '.webp' else image.save(path) + + +logo = render(variant(), 1024) +foreground = render(variant('-38 -38 176 176', 432), 432) +mono = render(variant('-38 -38 176 176', 432, mono=True), 432) +(ART / 'aperture-logo-monochrome.svg').write_text(variant(mono=True) + '\n') +(ART / 'aperture-launcher-foreground.svg').write_text(variant('-38 -38 176 176', 432) + '\n') +(ART / 'aperture-launcher-monochrome.svg').write_text(variant('-38 -38 176 176', 432, mono=True) + '\n') +# Retain the old path as a compatibility copy for existing external links. +(ART / 'aperture-logo-glass.svg').write_text((ART / 'aperture-logo.svg').read_text()) +for density, size, layer in [('mdpi', 48, 108), ('hdpi', 72, 162), ('xhdpi', 96, 216), + ('xxhdpi', 144, 324), ('xxxhdpi', 192, 432)]: + resource = MAIN / 'res' / f'mipmap-{density}' + exported = MAIN / 'assets/res' / f'mipmap-{density}' + save(tile(logo, size), resource / 'ic_launcher.webp') + save(tile(logo, size, 'circle'), resource / 'ic_launcher_round.webp') + save(foreground.resize((layer, layer), Image.Resampling.LANCZOS), resource / 'ic_launcher_foreground.webp') + save(mono.resize((layer, layer), Image.Resampling.LANCZOS), resource / 'ic_launcher_monochrome.webp') + save(tile(logo, size), exported / 'ic_launcher.png') + save(Image.new('RGBA', (layer, layer), BACKGROUND), exported / 'ic_launcher_background.png') + save(foreground.resize((layer, layer), Image.Resampling.LANCZOS), exported / 'ic_launcher_foreground.png') + save(mono.resize((layer, layer), Image.Resampling.LANCZOS), exported / 'ic_launcher_monochrome.png') +save(tile(logo, 512, 'square'), MAIN / 'ic_launcher-playstore.png') +save(tile(logo, 1024), ROOT / 'Aperture.png') + +# The banner's wordmark is vector outlines from the app's own Outfit SemiBold font. +font = TTFont(MAIN / 'res/font/outfit_semibold.ttf') +glyphs = font.getGlyphSet() +cmap = font.getBestCmap() +advance = 0 +word = [] +for char in 'Aperture': + glyph_name = cmap[ord(char)] + pen = SVGPathPen(glyphs) + glyphs[glyph_name].draw(pen) + word.append(f'') + advance += font['hmtx'][glyph_name][0] +scale = 640 / advance +root = deepcopy(SOURCE) +logo_parts = ''.join(ET.tostring(e, encoding='unicode') for e in root if e.tag.endswith(('defs', 'g', 'path'))) +banner_body = (f'{logo_parts}' + f'' + ''.join(word) + '') +header = f'' +banner_background_body = ''' + + + + + + + + + + + + + +''' +banner_background_svg = header + banner_background_body + '\n' +banner_svg = header + banner_background_body + banner_body + '\n' +(ART / 'aperture-banner.svg').write_text(banner_svg) +(ART / 'aperture-banner-background.svg').write_text(banner_background_svg) +full_banner = render(banner_svg, 1280) +foreground_banner = render(header + banner_body + '', 1280) +# AdaptiveIconDrawable expands each child by 25% on every side (1.5x total). +# Expand the source viewport by the same amount so the visible result matches +# the flat banner. Only launcher foregrounds get this padding; onboarding uses +# the full-size drawable foreground below. +adaptive_header = header.replace('viewBox="0 0 1280 720"', + 'viewBox="-320 -180 1920 1080"') +adaptive_banner_svg = adaptive_header + banner_body + '\n' +(ART / 'aperture-banner-foreground.svg').write_text(adaptive_banner_svg) +adaptive_foreground_banner = render(adaptive_banner_svg, 1280) +adaptive_background_svg = (adaptive_header + + f'' + + banner_background_body + '\n') +(ART / 'aperture-banner-background-adaptive.svg').write_text(adaptive_background_svg) +adaptive_background_banner = render(adaptive_background_svg, 1280) +for p in [MAIN / 'assets/ic_banner.png', MAIN / 'res/drawable/ic_banner.png']: + save(full_banner, p) +save(foreground_banner, MAIN / 'res/drawable/ic_banner_foreground.png') +for density, size in [('mdpi', (160, 90)), ('hdpi', (240, 135)), ('xhdpi', (320, 180)), + ('xxhdpi', (480, 270)), ('xxxhdpi', (640, 360))]: + resource = MAIN / 'res' / f'mipmap-{density}' + save(full_banner.resize(size, Image.Resampling.LANCZOS), resource / 'ic_banner.png') + save(adaptive_foreground_banner.resize(size, Image.Resampling.LANCZOS), + resource / 'ic_banner_foreground.png') + save(adaptive_background_banner.resize(size, Image.Resampling.LANCZOS), + resource / 'ic_banner_background_art.png') +for name in ['ic_launcher_background']: + (MAIN / 'res/values' / f'{name}.xml').write_text( + f'\n\n {BACKGROUND}\n\n') +for svg in ART.glob('*.svg'): + svg.write_text('\n'.join(line.rstrip() for line in svg.read_text().splitlines()) + '\n') +print('Regenerated launcher, monochrome, legacy and layered TV banner assets from artwork/aperture-logo.svg.')