Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/mobile-featured-hero-iphone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions iosApp/Tests/ClientLocalSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,40 @@ final class ClientLocalSettingsTests: XCTestCase {
XCTAssertTrue(defaults.containsObject(forKey: key))
}

func testShowFeaturedHeroDefaultsOnAndPersistsAtItsInjectedProfileScope() throws {
let suiteName = "client-local-hero-suite-\(UUID().uuidString)"
let standardName = "client-local-hero-standard-\(UUID().uuidString)"
let suite = try XCTUnwrap(UserDefaults(suiteName: suiteName))
let standard = try XCTUnwrap(UserDefaults(suiteName: standardName))
defer {
UserDefaults().removePersistentDomain(forName: suiteName)
UserDefaults().removePersistentDomain(forName: standardName)
}
let defaults = SharedDefaults(suite: suite, standard: standard)
let key = "ios.home.showFeaturedHero.test-server.test-profile"
let cardsKey = "ios.home.useFeaturedHeroCards.test-server.test-profile"

let settings = AppNavPreferences(
defaults: defaults,
featuredHeroStorageKey: { key },
featuredHeroCardsStorageKey: { cardsKey }
)
XCTAssertTrue(settings.showFeaturedHero)
XCTAssertTrue(settings.useFeaturedHeroCards)
settings.setShowFeaturedHero(false)
settings.setUseFeaturedHeroCards(false)

let restored = AppNavPreferences(
defaults: defaults,
featuredHeroStorageKey: { key },
featuredHeroCardsStorageKey: { cardsKey }
)
XCTAssertFalse(restored.showFeaturedHero)
XCTAssertFalse(restored.useFeaturedHeroCards)
XCTAssertTrue(defaults.containsObject(forKey: key))
XCTAssertTrue(defaults.containsObject(forKey: cardsKey))
}

@MainActor
func testMatchDeviceCaptionsOverridesServerAppearanceAndManualEditingTakesBackControl() async throws {
let harness = try PlayerSettingsHarness()
Expand Down
59 changes: 56 additions & 3 deletions iosApp/Tests/HomeSectionsMutationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,58 @@ final class HomeSectionsMutationTests: XCTestCase {
case failed
}

func testFeaturedAudiobookPlayOpensDetailsInsteadOfVideoPlayer() throws {
let item = try makeItem(contentId: "audiobook", type: "audiobook")
var events: [String] = []

dispatchFeaturedHeroPlay(
item,
onVideoPlay: { events.append("play:\($0.contentId)") },
onInfo: { events.append("info:\($0.contentId)") }
)

XCTAssertEqual(events, ["info:audiobook"])
}

func testFeaturedMoviePlayKeepsVideoPlayerRoute() throws {
let item = try makeItem(contentId: "movie")
var events: [String] = []

dispatchFeaturedHeroPlay(
item,
onVideoPlay: { events.append("play:\($0.contentId)") },
onInfo: { events.append("info:\($0.contentId)") }
)

XCTAssertEqual(events, ["play:movie"])
}

@MainActor
func testOnlyTopFeaturedSectionBecomesHeroAndLaterFeaturedSectionRemainsARow() throws {
let item = try makeItem(contentId: "item")
let viewModel = HomeViewModel()
viewModel.sections = [
makeSection(id: "featured-first", type: "custom", totalCount: 1, featured: true, items: [item]),
makeSection(id: "featured-later", type: "custom", totalCount: 1, featured: true, items: [item]),
]

XCTAssertEqual(viewModel.featuredSection?.id, "featured-first")
XCTAssertEqual(viewModel.regularSections.map(\.id), ["featured-later"])
}

@MainActor
func testFeaturedSectionBelowTopRowDoesNotBecomeHero() throws {
let item = try makeItem(contentId: "item")
let viewModel = HomeViewModel()
viewModel.sections = [
makeSection(id: "regular", type: "recently_added", totalCount: 1, items: [item]),
makeSection(id: "featured-later", type: "custom", totalCount: 1, featured: true, items: [item]),
]

XCTAssertNil(viewModel.featuredSection)
XCTAssertEqual(viewModel.regularSections.map(\.id), ["regular", "featured-later"])
}

func testRemovesItemOnlyFromContinueWatchingSections() throws {
let target = try makeItem(contentId: "target")
let other = try makeItem(contentId: "other")
Expand Down Expand Up @@ -204,11 +256,11 @@ final class HomeSectionsMutationTests: XCTestCase {
XCTAssertTrue(viewModel.isShowingActionError)
}

private func makeItem(contentId: String) throws -> SectionItem {
private func makeItem(contentId: String, type: String = "movie") throws -> SectionItem {
let json = """
{
"contentId": "\(contentId)",
"type": "movie",
"type": "\(type)",
"title": "Test Item",
"progressUpdatedAt": "2026-07-10T12:00:00Z"
}
Expand All @@ -220,13 +272,14 @@ final class HomeSectionsMutationTests: XCTestCase {
id: String,
type: String,
totalCount: Int?,
featured: Bool = false,
items: [SectionItem]
) -> ResolvedSection {
ResolvedSection(
id: id,
sectionType: type,
title: id,
featured: false,
featured: featured,
itemLimit: nil,
totalCount: totalCount,
isCustom: false,
Expand Down
37 changes: 34 additions & 3 deletions iosApp/Tests/ImageSizeCapabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ final class ImageSizeCapabilityTests: XCTestCase {
"logo": {"small": 300, "medium": 500, "large": 1280},
"backdrop": {"small": 300, "medium": 780, "large": 1920}
},
"original_max_width_px": 1920
"original_max_width_px": 1920,
"textless_poster": {
"endpoint": "/api/v1/catalog/items/{id}/images/textless-poster",
"supported_types": ["movie", "series"]
}
}
"""

Expand All @@ -59,6 +63,11 @@ final class ImageSizeCapabilityTests: XCTestCase {
XCTAssertEqual(capability.widths["poster"]?["large"], 780)
XCTAssertEqual(capability.widths["logo"]?["large"], 1280)
XCTAssertEqual(capability.widths["backdrop"]?["large"], 1920)
XCTAssertEqual(
capability.textlessPoster?.endpoint,
"/api/v1/catalog/items/{id}/images/textless-poster"
)
XCTAssertEqual(capability.textlessPoster?.supportedTypes, ["movie", "series"])
}

/// Roles the client doesn't know about must not fail the decode —
Expand All @@ -80,6 +89,24 @@ final class ImageSizeCapabilityTests: XCTestCase {
XCTAssertEqual(capability.widths["thumb"]?["large"], 480)
}

func testTextlessPosterEndpointOnlySupportsAdvertisedContentTypes() throws {
let capability = try decodedCapability()
XCTAssertEqual(
ImageSizeCapability.textlessPosterEndpoint(capability: capability, for: "movie"),
"/api/v1/catalog/items/{id}/images/textless-poster"
)
XCTAssertEqual(
ImageSizeCapability.textlessPosterEndpoint(capability: capability, for: "SERIES"),
"/api/v1/catalog/items/{id}/images/textless-poster"
)
XCTAssertNil(
ImageSizeCapability.textlessPosterEndpoint(capability: capability, for: "episode")
)
XCTAssertNil(
ImageSizeCapability.textlessPosterEndpoint(capability: capability, for: "audiobook")
)
}

// MARK: - Query injection

func testQueryEntriesAddLargeWhenSupportedOnTV() throws {
Expand Down Expand Up @@ -190,7 +217,7 @@ final class ImageSizeCapabilityTests: XCTestCase {
XCTAssertEqual(capability.requestQuery, ["image_size": "large"])
}

func testFailedRefreshRetriesAndThenCachesSuccess() async throws {
func testFailedRefreshIsCachedUntilExplicitRetry() async throws {
let response = try decodedCapability()
let stub = ImageSizeCapabilityFetchStub(
response: response,
Expand All @@ -204,9 +231,13 @@ final class ImageSizeCapabilityTests: XCTestCase {
XCTAssertTrue(capability.requestQuery.isEmpty)

await capability.refresh()
var callCount = await stub.callCount
XCTAssertEqual(callCount, 1)

await capability.retryUnavailable()
await capability.refresh()

let callCount = await stub.callCount
callCount = await stub.callCount
XCTAssertEqual(callCount, 2)
XCTAssertEqual(capability.requestQuery, ["image_size": "large"])
}
Expand Down
59 changes: 44 additions & 15 deletions iosApp/iosApp/Components/TabTopBarActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,27 @@ struct TabTopBarActions: View {
let onSwitchProfile: () -> Void
let onSwitchServer: () -> Void
let onSignOut: () -> Void
var usesGlassCircles = false

var body: some View {
// Plain icon glyphs (no glass chip) spaced evenly, matching the
// clean top-right cluster used by Plex. The profile avatar is the
// only filled shape, so it reads as the account control.
HStack(spacing: ContinuumTheme.topBarIconSpacing) {
TopBarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "Search", action: onSearch)
TopBarIconButton(
systemImage: "magnifyingglass",
accessibilityLabel: "Search",
usesGlassCircle: usesGlassCircles,
action: onSearch
)
ProfileAvatarMenu(
profile: profile,
onOpenSettings: onOpenSettings,
onOpenRequests: onOpenRequests,
onSwitchProfile: onSwitchProfile,
onSwitchServer: onSwitchServer,
onSignOut: onSignOut
onSignOut: onSignOut,
usesGlassCircle: usesGlassCircles
)
}
}
Expand All @@ -41,19 +48,38 @@ struct TabTopBarActions: View {
private struct TopBarIconButton: View {
let systemImage: String
let accessibilityLabel: String
let usesGlassCircle: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.continuumOnSurface)
.frame(width: ContinuumTheme.topBarIconHitSize, height: ContinuumTheme.topBarIconHitSize)
.contentShape(Rectangle())
buttonLabel
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}

@ViewBuilder
private var buttonLabel: some View {
let icon = Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.continuumOnSurface)
.frame(
width: ContinuumTheme.topBarIconHitSize,
height: ContinuumTheme.topBarIconHitSize
)
.contentShape(Circle())

if usesGlassCircle {
icon.siloGlass(
in: Circle(),
tint: Color.black.opacity(0.18),
interactive: true
)
} else {
icon
}
}
}

/// Profile avatar rendered via `ProfileAvatarView` (which handles DiceBear
Expand All @@ -67,6 +93,7 @@ private struct ProfileAvatarMenu: View {
let onSwitchProfile: () -> Void
let onSwitchServer: () -> Void
let onSignOut: () -> Void
let usesGlassCircle: Bool

/// Capability-gated: the Requests row exists only when the server has
/// the feature enabled (older servers 404 the probe and read as off).
Expand Down Expand Up @@ -109,14 +136,16 @@ private struct ProfileAvatarMenu: View {
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
ProfileAvatarView(
avatar: profile?.avatarEmoji,
imageUrl: profile?.avatarImageUrl,
name: profile?.name ?? "",
size: 36
)
}
} label: { avatarLabel }
.menuStyle(.borderlessButton)
}

private var avatarLabel: some View {
ProfileAvatarView(
avatar: profile?.avatarEmoji,
imageUrl: profile?.avatarImageUrl,
name: profile?.name ?? "",
size: usesGlassCircle ? ContinuumTheme.topBarIconHitSize : 36
)
}
}
8 changes: 4 additions & 4 deletions iosApp/iosApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,11 @@ struct ContentView: View {
#endif
Task { await hydrateOverlayPrefs(phase: "foreground_refresh") }
// Same rationale as overlay hydration above: a transiently-failed
// capability probe (or one skipped on a cold restore) gets a
// natural retry on foreground. `refresh()` is idempotent, so the
// happy path costs nothing.
// capability probe (or one skipped on a cold restore) gets an
// explicit retry on foreground. Successful probes remain cached,
// so the happy path costs nothing.
Task { await AICapabilities.shared.refresh() }
Task { await ImageSizeCapability.shared.refresh() }
Task { await ImageSizeCapability.shared.retryUnavailable() }
Task { await RequestsFeatureStore.shared.refresh() }
Task { await SubtitleProvidersStore.shared.refresh() }
Task { await uiCustomization.refresh() }
Expand Down
27 changes: 17 additions & 10 deletions iosApp/iosApp/Control/iOS/SiloControlModeButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct SiloControlModeButton: View {
@Bindable var controller: SiloControlClient
var usesGlassCircle = false
let onChooseTarget: () -> Void

var body: some View {
Expand Down Expand Up @@ -32,21 +33,27 @@ struct SiloControlModeButton: View {
}
}

@ViewBuilder
private func buttonLabel(isActive: Bool) -> some View {
Image(systemName: "appletvremote.gen4")
let icon = Image(systemName: "appletvremote.gen4")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isActive ? Color.continuumBackground : Color.continuumOnSurface)
.frame(width: ContinuumTheme.topBarIconHitSize, height: ContinuumTheme.topBarIconHitSize)
.background {
// Chrome-free at rest (Plex-style); a filled disc appears only
// while actively controlling a TV so the state stays obvious.
if isActive {
Circle()
.fill(Color.continuumOnSurface)
.frame(width: 36, height: 36)
}
}
.contentShape(Circle())

if isActive {
icon.background {
Circle().fill(Color.continuumOnSurface)
}
} else if usesGlassCircle {
icon.siloGlass(
in: Circle(),
tint: Color.black.opacity(0.18),
interactive: true
)
} else {
icon
}
}
}

Expand Down
Loading