Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -964,15 +964,19 @@ the whole session, so without this the app has a live game and a page that looks
- **Banner.** Pinned with a top `safeAreaInset` on the page's scroll view, so it stays visible
while the page scrolls — the one fact that holds for the whole session, needed most when the user
has scrolled down to the rails. A pinned `Section` header would require a `LazyVStack`, which the
home page deliberately avoids. Chrome and control are the active-session banner's
(`VendorActiveSessionBannerButtonStyle`):
`OPNDesign.Surface.chrome`, a 1px Stroke Subtle hairline along the bottom,
`CatalogVendorLayout.sectionHeaderMargin` horizontal padding, a 8pt accent dot, a 10pt bold accent
eyebrow ("STREAM RUNNING", tracking 1.2), then the game title at 14pt bold with the stream's own
status message under it at 11pt Text Secondary. Actions: **FOCUS** (accent fill, the one action the
session banner has no equivalent for) and **END** (neutral fill, 1px Stroke Regular). END routes
through `StreamSessionLifecycle`, so the menu bar, the PiP strip and this button tear down the same
thing.
home page deliberately avoids. That inset's region is the scroll view's *frame*, and a `ScrollView`
is as wide as its widest content — a rail still on its skeleton runs past the window. The page
therefore clamps the scroll view to the page width (`CatalogContentView`): without that clamp the
banner was laid out centred on the inflated width, which left only its right-hand end on screen.
Chrome and control are the active-session banner's, shared through `VendorStatusBannerChrome` and
`VendorActiveSessionBannerButtonStyle`: `OPNDesign.Surface.chrome`, a 1px Stroke Subtle hairline
along the bottom, `CatalogVendorLayout.sectionHeaderMargin` horizontal padding, a 8pt accent dot, a
10pt bold accent eyebrow ("STREAM RUNNING", tracking 1.2), then the game title at 14pt bold. Two
lines, not three: the stream's own status message under the title only restated that it plays in
its own window, and cost the banner a row of height. Actions: **FOCUS** (accent fill, the one
action the session banner has no equivalent for) and **END** (neutral fill, 1px Stroke Regular).
END routes through `StreamSessionLifecycle`, so the menu bar, the PiP strip and this button tear
down the same thing.
- **Backdrop.** The running stream's artwork, artwork fill, 18pt blur, then `OPNDesign.Surface.scrim`
and the same top/bottom black gradient the store picker uses. Behind the page, never hit-testable,
hidden from accessibility. Games page only — Settings and Recordings share the stack and have
Expand Down
78 changes: 78 additions & 0 deletions Tests/Catalog/CatalogRunningStreamBannerLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// The running-stream banner's width: the page clamps its scroll view to the page, because the
// banner's `safeAreaInset` region is that frame and a `ScrollView` is as wide as its content.

import AppKit
import SwiftUI
import Testing
@testable import OpenNOW

@MainActor
@Suite struct CatalogRunningStreamBannerLayoutTests {
private static let pageSize = CGSize(width: 900, height: 620)
/// The banner is pinned to the top of the page, so row 4 is banner and the bottom row is bare page.
private static let bannerSampleRow = 4
private static let pageSampleRowFromBottom = 20
/// ImageRenderer rasterizes in its own colour space, so rendered pixels are compared to each other.
private static let channelTolerance = 0.01

@Test func theBannerReachesBothEdgesOfThePageWhileARailRunsPastIt() throws {
let bitmap = try renderRunningStreamPage()
let leadingEdge = try renderedColor(in: bitmap, x: 0, y: Self.bannerSampleRow)
let trailingEdge = try renderedColor(in: bitmap, x: bitmap.pixelsWide - 1, y: Self.bannerSampleRow)
let pageBackground = try renderedColor(in: bitmap, x: 0, y: bitmap.pixelsHigh - Self.pageSampleRowFromBottom)

// Both ends of the top band are the banner's chrome ...
#expect(
isSameColor(leadingEdge, trailingEdge),
"the top band is two colours: \(rgbTriplet(of: leadingEdge)) leading, \(rgbTriplet(of: trailingEdge)) trailing"
)
// ... and it is the banner, not the page showing through where the banner should be.
#expect(
!isSameColor(leadingEdge, pageBackground),
"the banner is missing from the leading edge of the page"
)
}

/// ImageRenderer draws the inset even though it skips `ScrollView` content, and the layout that
/// places that inset still runs against the content's width - which is the behaviour under test.
private func renderRunningStreamPage() throws -> NSBitmapImageRep {
let page = CatalogContentView(viewModel: makeRunningStreamViewModel(), isActive: true)
.frame(width: Self.pageSize.width, height: Self.pageSize.height)
let renderer = ImageRenderer(content: page)
renderer.scale = 1
let image = try #require(renderer.cgImage, "the page did not render")
return NSBitmapImageRep(cgImage: image)
}

/// A running stream over a page whose only rail is still loading: a plain row of six fixed-width
/// tiles, which is what makes the scroll content wider than the page.
private func makeRunningStreamViewModel() -> CatalogViewModel {
OPNDesign.applyTheme(accent: .cloudGreen, appearance: .dark, systemColorScheme: .dark)
let viewModel = makeCatalogViewModelForTesting()
viewModel.cachedCatalogSections = [
CatalogSectionModel(id: "loading", title: "Loading", games: [], kind: .catalog, isPlaceholder: true)
]
viewModel.activeStreamConfiguration = StreamLaunchConfiguration(
title: "The Witcher 3: Wild Hunt - Remastered",
applicationID: "app-1",
accessToken: "token",
accountLinked: true,
selectedStore: "STEAM"
)
return viewModel
}

private func renderedColor(in bitmap: NSBitmapImageRep, x: Int, y: Int) throws -> NSColor {
try #require(bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), "no pixel at \(x),\(y)")
}

private func isSameColor(_ first: NSColor, _ second: NSColor) -> Bool {
abs(first.redComponent - second.redComponent) < Self.channelTolerance
&& abs(first.greenComponent - second.greenComponent) < Self.channelTolerance
&& abs(first.blueComponent - second.blueComponent) < Self.channelTolerance
}

private func rgbTriplet(of color: NSColor) -> String {
"(\(Int(color.redComponent * 255)),\(Int(color.greenComponent * 255)),\(Int(color.blueComponent * 255)))"
}
}
8 changes: 6 additions & 2 deletions View/Catalog/CatalogContentViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ struct CatalogContentView: View {
let sections = viewModel.catalogSections
let isGridDestination = shouldUseGrid(for: viewModel.selectedCatalogDestination)
ScrollViewReader { proxy in
ScrollView {
// No indicator, like every other scroll surface here: the page only ever lacked
// one because its frame was as wide as the scroll content.
ScrollView(.vertical, showsIndicators: false) {
// Deliberately eager. A LazyVStack here re-runs
// `LazyStack.measureEstimates` on every scroll offset change, and
// estimating a rail means applying its whole view list - every tile in
Expand All @@ -58,7 +60,6 @@ struct CatalogContentView: View {
VendorActiveSessionHomeBanner(
title: viewModel.activeHomeSessionTitle,
isResumable: session.isResumable,
serverIp: session.serverIp,
availableWidth: viewport.size.width,
onResume: { viewModel.resumeActiveHomeSession() },
onEnd: { viewModel.endActiveHomeSession() }
Expand Down Expand Up @@ -197,6 +198,9 @@ struct CatalogContentView: View {
scrollToSelectedRail(selectedRailScrollAnchor, proxy: proxy)
}
}
// A `ScrollView` is as wide as its widest content, and the running-stream banner's
// `safeAreaInset` region is that frame - so the region is clamped to the page.
.frame(maxWidth: viewport.size.width > 0 ? viewport.size.width : .infinity, alignment: .leading)
// Sticky, not scrolled: the banner is the one fact that stays true for the whole
// session, and a page scrolled down to the rails is exactly when it is needed.
// A `safeAreaInset` keeps it pinned above the content - a pinned `Section` header
Expand Down
46 changes: 6 additions & 40 deletions View/Catalog/CatalogRailViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -424,52 +424,18 @@ struct CatalogPanelActionTile: View {
struct VendorActiveSessionHomeBanner: View {
let title: String
let isResumable: Bool
let serverIp: String
var availableWidth: CGFloat = 0
let onResume: () -> Void
let onEnd: () -> Void

@Environment(\.opnUIScale) private var uiScale

var body: some View {
HStack(spacing: 0) {
Circle()
.fill(OPNDesign.accent)
.frame(width: 8 * uiScale, height: 8 * uiScale)
.padding(.trailing, 10 * uiScale)

VStack(alignment: .leading, spacing: 2 * uiScale) {
Text("SESSION ACTIVE")
.catalogFont(size: 10, weight: .bold)
.foregroundStyle(OPNDesign.accentInk)
.tracking(1.2)
Text(title)
.catalogFont(size: 14, weight: .bold)
.foregroundStyle(OPNDesign.Text.primary)
.lineLimit(1)
}

Spacer(minLength: 16 * uiScale)

HStack(spacing: 8 * uiScale) {
if isResumable {
Button("RESUME") { onResume() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true))
}
Button("END") { onEnd() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false))
VendorStatusBannerChrome(eyebrow: "SESSION ACTIVE", title: title, availableWidth: availableWidth) {
if isResumable {
Button("RESUME") { onResume() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true))
}
}
.padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale))
.padding(.vertical, 10 * uiScale)
// Clamp before the chrome so the background and hairline paint at the page width, not at
// the scroll view's inflated content width.
.frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading)
.background(OPNDesign.Surface.chrome)
.overlay(alignment: .bottom) {
Rectangle()
.fill(OPNDesign.Stroke.subtle)
.frame(height: 1)
Button("END") { onEnd() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false))
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions View/Catalog/CatalogStatusBannerChrome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The chrome both catalog status banners are drawn on - the running-stream banner and the
// active-session banner - so the surface, metrics and hairline are defined once.

import SwiftUI

/// The chrome a catalog status banner sits on: accent dot, eyebrow over title, actions trailing.
struct VendorStatusBannerChrome<Actions: View>: View {
let eyebrow: String
let title: String
var availableWidth: CGFloat = 0
@ViewBuilder let actions: Actions

@Environment(\.opnUIScale) private var uiScale

var body: some View {
HStack(spacing: 0) {
Circle()
.fill(OPNDesign.accent)
.frame(width: 8 * uiScale, height: 8 * uiScale)
.padding(.trailing, 10 * uiScale)

VStack(alignment: .leading, spacing: 2 * uiScale) {
Text(eyebrow)
.catalogFont(size: 10, weight: .bold)
.foregroundStyle(OPNDesign.accentInk)
.tracking(1.2)
Text(title)
.catalogFont(size: 14, weight: .bold)
.foregroundStyle(OPNDesign.Text.primary)
.lineLimit(1)
}

Spacer(minLength: 16 * uiScale)

HStack(spacing: 8 * uiScale) { actions }
}
.padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale))
.padding(.vertical, 10 * uiScale)
// Clamp before the chrome so the background and hairline paint at the page width, not at
// the scroll view's inflated content width.
.frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading)
.background(OPNDesign.Surface.chrome)
.overlay(alignment: .bottom) {
Rectangle()
.fill(OPNDesign.Stroke.subtle)
.frame(height: 1)
}
}
}
55 changes: 7 additions & 48 deletions View/Catalog/CatalogStreamStatusViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,61 +15,20 @@

import SwiftUI

/// The banner for a stream that is live elsewhere in the app.
///
/// END routes through `StreamSessionLifecycle`, the same registry the menu bar's End Session and the
/// PiP control strip use, so all three tear down exactly the same thing.
/// The banner for a stream that is live elsewhere in the app. END goes through
/// `StreamSessionLifecycle`, like the menu bar's End Session and the PiP control strip.
struct VendorRunningStreamHomeBanner: View {
let title: String
var availableWidth: CGFloat = 0
let onFocus: () -> Void
let onEnd: () -> Void

@Environment(\.opnUIScale) private var uiScale

var body: some View {
HStack(spacing: 0) {
Circle()
.fill(OPNDesign.accent)
.frame(width: 8 * uiScale, height: 8 * uiScale)
.padding(.trailing, 10 * uiScale)

VStack(alignment: .leading, spacing: 2 * uiScale) {
Text("STREAM RUNNING")
.catalogFont(size: 10, weight: .bold)
.foregroundStyle(OPNDesign.accentInk)
.tracking(1.2)
Text(title)
.catalogFont(size: 14, weight: .bold)
.foregroundStyle(OPNDesign.Text.primary)
.lineLimit(1)
// Deliberately constant rather than the last progress message: that is whatever the
// launch said on its way past ("Starting GeForce NOW stream..."), and a running
// stream showing that reads as a stall. What is true for the whole session is where
// the picture is.
Text("Playing in its own window")
.catalogFont(size: 11)
.foregroundStyle(OPNDesign.Text.secondary)
.lineLimit(1)
}

Spacer(minLength: 16 * uiScale)

HStack(spacing: 8 * uiScale) {
Button("FOCUS") { onFocus() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true))
Button("END") { onEnd() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false))
}
}
.padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale))
.padding(.vertical, 10 * uiScale)
.frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading)
.background(OPNDesign.Surface.chrome)
.overlay(alignment: .bottom) {
Rectangle()
.fill(OPNDesign.Stroke.subtle)
.frame(height: 1)
VendorStatusBannerChrome(eyebrow: "STREAM RUNNING", title: title, availableWidth: availableWidth) {
Button("FOCUS") { onFocus() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true))
Button("END") { onEnd() }
.buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false))
}
}
}
Expand Down
1 change: 0 additions & 1 deletion View/Catalog/ControllerCatalogView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,6 @@ private struct ControllerGamesPage: View {
VendorActiveSessionHomeBanner(
title: viewModel.activeHomeSessionTitle,
isResumable: session.isResumable,
serverIp: session.serverIp,
availableWidth: layout.contentWidth,
onResume: { viewModel.resumeActiveHomeSession() },
onEnd: { viewModel.endActiveHomeSession() }
Expand Down