diff --git a/.github/pr-assets/mobile-featured-hero-iphone-lanterns.png b/.github/pr-assets/mobile-featured-hero-iphone-lanterns.png new file mode 100644 index 00000000..4bf553b0 Binary files /dev/null and b/.github/pr-assets/mobile-featured-hero-iphone-lanterns.png differ diff --git a/.github/pr-assets/mobile-featured-hero-iphone-no-card.png b/.github/pr-assets/mobile-featured-hero-iphone-no-card.png new file mode 100644 index 00000000..f8b30f7b Binary files /dev/null and b/.github/pr-assets/mobile-featured-hero-iphone-no-card.png differ diff --git a/.github/pr-assets/mobile-featured-hero-iphone.png b/.github/pr-assets/mobile-featured-hero-iphone.png new file mode 100644 index 00000000..2b754628 Binary files /dev/null and b/.github/pr-assets/mobile-featured-hero-iphone.png differ diff --git a/iosApp/Tests/ClientLocalSettingsTests.swift b/iosApp/Tests/ClientLocalSettingsTests.swift index 0e4e42ab..f6c51767 100644 --- a/iosApp/Tests/ClientLocalSettingsTests.swift +++ b/iosApp/Tests/ClientLocalSettingsTests.swift @@ -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() diff --git a/iosApp/Tests/HomeSectionsMutationTests.swift b/iosApp/Tests/HomeSectionsMutationTests.swift index 148ddaa2..59c4fd84 100644 --- a/iosApp/Tests/HomeSectionsMutationTests.swift +++ b/iosApp/Tests/HomeSectionsMutationTests.swift @@ -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") @@ -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" } @@ -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, diff --git a/iosApp/Tests/ImageSizeCapabilityTests.swift b/iosApp/Tests/ImageSizeCapabilityTests.swift index b70297dd..7a940516 100644 --- a/iosApp/Tests/ImageSizeCapabilityTests.swift +++ b/iosApp/Tests/ImageSizeCapabilityTests.swift @@ -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"] + } } """ @@ -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 — @@ -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 { @@ -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, @@ -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"]) } diff --git a/iosApp/iosApp/Components/TabTopBarActions.swift b/iosApp/iosApp/Components/TabTopBarActions.swift index 6bbe9d63..344c6aba 100644 --- a/iosApp/iosApp/Components/TabTopBarActions.swift +++ b/iosApp/iosApp/Components/TabTopBarActions.swift @@ -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 ) } } @@ -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 @@ -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). @@ -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 + ) + } } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 3cd4c657..41267eab 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -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() } diff --git a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift index 97c5b196..e52f4d3f 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift @@ -3,6 +3,7 @@ import SwiftUI struct SiloControlModeButton: View { @Bindable var controller: SiloControlClient + var usesGlassCircle = false let onChooseTarget: () -> Void var body: some View { @@ -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 + } } } diff --git a/iosApp/iosApp/Navigation/AppNavPreferences.swift b/iosApp/iosApp/Navigation/AppNavPreferences.swift index ccfc3233..1e245fbd 100644 --- a/iosApp/iosApp/Navigation/AppNavPreferences.swift +++ b/iosApp/iosApp/Navigation/AppNavPreferences.swift @@ -12,17 +12,35 @@ final class AppNavPreferences { /// Whether audiobook library/search surfaces are shown for this platform. private(set) var showAudiobooks: Bool + /// Whether iPhone and iPad Home promote the featured section into a hero. + private(set) var showFeaturedHero: Bool + /// Whether the mobile featured hero uses rounded poster-card framing. + private(set) var useFeaturedHeroCards: Bool @ObservationIgnored private let defaults: SharedDefaults @ObservationIgnored private let storageKey: () -> String? + @ObservationIgnored private let featuredHeroStorageKey: () -> String? + @ObservationIgnored private let featuredHeroCardsStorageKey: () -> String? init( defaults: SharedDefaults = .shared, - storageKey: @escaping () -> String? = AppNavPreferences.showAudiobooksKey + storageKey: @escaping () -> String? = AppNavPreferences.showAudiobooksKey, + featuredHeroStorageKey: @escaping () -> String? = AppNavPreferences.showFeaturedHeroKey, + featuredHeroCardsStorageKey: @escaping () -> String? = AppNavPreferences.useFeaturedHeroCardsKey ) { self.defaults = defaults self.storageKey = storageKey + self.featuredHeroStorageKey = featuredHeroStorageKey + self.featuredHeroCardsStorageKey = featuredHeroCardsStorageKey self.showAudiobooks = Self.readShowAudiobooks(from: defaults, key: storageKey()) + self.showFeaturedHero = Self.readShowFeaturedHero( + from: defaults, + key: featuredHeroStorageKey() + ) + self.useFeaturedHeroCards = Self.readUseFeaturedHeroCards( + from: defaults, + key: featuredHeroCardsStorageKey() + ) } /// Persist the choice for the active profile and update the observed @@ -33,10 +51,32 @@ final class AppNavPreferences { defaults.set(value, forKey: key) } + /// Persist hero visibility locally and update Home immediately. + func setShowFeaturedHero(_ value: Bool) { + showFeaturedHero = value + guard let key = featuredHeroStorageKey() else { return } + defaults.set(value, forKey: key) + } + + /// Switch between rounded poster cards and an unframed full-width hero. + func setUseFeaturedHeroCards(_ value: Bool) { + useFeaturedHeroCards = value + guard let key = featuredHeroCardsStorageKey() else { return } + defaults.set(value, forKey: key) + } + /// Re-read the active profile's stored value. Call once the profile is /// known or after switching servers in place. func refresh() { showAudiobooks = Self.readShowAudiobooks(from: defaults, key: storageKey()) + showFeaturedHero = Self.readShowFeaturedHero( + from: defaults, + key: featuredHeroStorageKey() + ) + useFeaturedHeroCards = Self.readUseFeaturedHeroCards( + from: defaults, + key: featuredHeroCardsStorageKey() + ) } // MARK: - Storage @@ -49,6 +89,22 @@ final class AppNavPreferences { return defaults.bool(forKey: key) } + private static func readShowFeaturedHero(from defaults: SharedDefaults, key: String?) -> Bool { + guard let key else { return defaultShowFeaturedHero } + guard defaults.containsObject(forKey: key) else { + return defaultShowFeaturedHero + } + return defaults.bool(forKey: key) + } + + private static func readUseFeaturedHeroCards(from defaults: SharedDefaults, key: String?) -> Bool { + guard let key else { return defaultUseFeaturedHeroCards } + guard defaults.containsObject(forKey: key) else { + return defaultUseFeaturedHeroCards + } + return defaults.bool(forKey: key) + } + private static func showAudiobooksKey() -> String? { // No profile -> nothing to scope. Persisting under an anonymous key // would leak one user's choice into the next signed-in profile. @@ -59,9 +115,27 @@ final class AppNavPreferences { return "\(storagePrefix).\(serverId).\(profileId)" } + private static func showFeaturedHeroKey() -> String? { + guard let profileId = AuthService.shared.profileId, !profileId.isEmpty else { + return nil + } + let serverId = ServerRegistry.shared.activeServerId ?? "default" + return "\(featuredHeroStoragePrefix).\(serverId).\(profileId)" + } + + private static func useFeaturedHeroCardsKey() -> String? { + guard let profileId = AuthService.shared.profileId, !profileId.isEmpty else { + return nil + } + let serverId = ServerRegistry.shared.activeServerId ?? "default" + return "\(featuredHeroCardsStoragePrefix).\(serverId).\(profileId)" + } + /// Contract default for `nav.show_audiobooks`: this is an opt-in surface /// on every Apple platform. A stored per-profile choice still wins. private static let defaultShowAudiobooks = false + private static let defaultShowFeaturedHero = true + private static let defaultUseFeaturedHeroCards = true private static var storagePrefix: String { #if os(tvOS) @@ -74,4 +148,28 @@ final class AppNavPreferences { "apple.nav.showAudiobooks" #endif } + + private static var featuredHeroStoragePrefix: String { + #if os(iOS) + "ios.home.showFeaturedHero" + #elseif os(tvOS) + "skyline.home.showFeaturedHero" + #elseif os(macOS) + "mac.home.showFeaturedHero" + #else + "apple.home.showFeaturedHero" + #endif + } + + private static var featuredHeroCardsStoragePrefix: String { + #if os(iOS) + "ios.home.useFeaturedHeroCards" + #elseif os(tvOS) + "skyline.home.useFeaturedHeroCards" + #elseif os(macOS) + "mac.home.useFeaturedHeroCards" + #else + "apple.home.useFeaturedHeroCards" + #endif + } } diff --git a/iosApp/iosApp/Networking/ContinuumAPI.swift b/iosApp/iosApp/Networking/ContinuumAPI.swift index 6d196d4b..a5f8c4f2 100644 --- a/iosApp/iosApp/Networking/ContinuumAPI.swift +++ b/iosApp/iosApp/Networking/ContinuumAPI.swift @@ -74,6 +74,21 @@ actor ContinuumAPI { try await http.get("/api/v1/images/capability") } + /// Best available textless portrait poster for the mobile featured card. + /// Capability detection keeps older servers on the normal artwork fallback + /// without generating a stream of expected 404s. + func textlessPoster(contentId: String, contentType: String) async throws -> String? { + await ImageSizeCapability.shared.refresh() + guard let template = ImageSizeCapability.shared.textlessPosterEndpoint(for: contentType) else { + return nil + } + + let endpoint = template.replacingOccurrences(of: "{id}", with: contentId) + let response: TextlessPosterResponse = try await http.get(endpoint) + let posterUrl = response.posterUrl?.trimmingCharacters(in: .whitespacesAndNewlines) + return posterUrl?.isEmpty == false ? posterUrl : nil + } + // MARK: - Path-based dispatcher (legacy) func get(_ path: String, query: [String: String] = [:]) async throws -> T { diff --git a/iosApp/iosApp/Networking/ImageSizeCapability.swift b/iosApp/iosApp/Networking/ImageSizeCapability.swift index 57aa6db2..e478e76d 100644 --- a/iosApp/iosApp/Networking/ImageSizeCapability.swift +++ b/iosApp/iosApp/Networking/ImageSizeCapability.swift @@ -8,9 +8,9 @@ import Foundation /// fetched once per session and reset on sign-out and profile/server /// switch, with a generation counter so a probe still in flight across /// a reset discards its result instead of repopulating the next -/// account's capabilities. A `404`/network error leaves the slot `nil`, +/// account's capabilities. A `404`/network error is cached as unavailable, /// which ``isAvailable`` reads as "feature off", so older servers -/// degrade silently. +/// degrade silently without being probed again by every artwork request. /// /// Unlike `AICapabilities` this is **not** `@MainActor @Observable`: no /// view observes it, and its one consumer is the `ContinuumAPI` actor, @@ -62,10 +62,16 @@ final class ImageSizeCapability: @unchecked Sendable { let task: Task } + private enum ProbeState { + case unknown + case available(ImageSizeCapabilityResponse) + case unavailable + } + private let fetchCapability: @Sendable () async throws -> ImageSizeCapabilityResponse private let prefersLargeImages: Bool private let lock = NSLock() - private var storedCapability: ImageSizeCapabilityResponse? + private var probeState = ProbeState.unknown private var generation = 0 private var nextProbeID = 0 private var inFlightProbe: Probe? @@ -88,9 +94,13 @@ final class ImageSizeCapability: @unchecked Sendable { // MARK: - Gating convenience - /// The decoded probe, or `nil` before it lands / after `reset()`. + /// The decoded probe, or `nil` before it lands, after `reset()`, or when + /// the latest probe established that the capability is unavailable. var capability: ImageSizeCapabilityResponse? { - lock.withLock { storedCapability } + lock.withLock { + guard case let .available(capability) = probeState else { return nil } + return capability + } } /// Whether this client will actually send a size on this platform. @@ -108,17 +118,16 @@ final class ImageSizeCapability: @unchecked Sendable { // MARK: - Lifecycle - /// Probe the server once per session. Failure-tolerant and - /// idempotent: a `404` from an older server, or any transport - /// error, leaves the feature off and is retried on the next - /// foreground refresh. + /// Probe the server once per session. Failure-tolerant and idempotent: a + /// `404` from an older server, or any transport error, leaves the feature + /// off until ``retryUnavailable()`` runs at the next foreground edge. /// - /// Skipped entirely on platforms that wouldn't send the parameter, - /// so iOS and macOS don't pay for a request they can't use. + /// All platforms probe because the same payload also advertises optional + /// artwork roles such as textless mobile posters. Platforms that do not + /// want a larger image size still leave ``requestQuery`` empty. func refresh() async { - guard prefersLargeImages else { return } guard let probe = lock.withLock({ () -> Probe? in - if storedCapability != nil { return nil } + guard case .unknown = probeState else { return nil } if let inFlightProbe, inFlightProbe.generation == generation { return inFlightProbe } @@ -135,22 +144,58 @@ final class ImageSizeCapability: @unchecked Sendable { let probed = await probe.task.value lock.withLock { // Discard if a reset happened while the probe was in flight, or a - // newer probe superseded this one. A nil result remains retryable - // on the next foreground; successful results are cached. + // newer probe superseded this one. Both successful and unavailable + // outcomes stay cached until an explicit lifecycle retry or reset. guard generation == probe.generation, inFlightProbe?.id == probe.id else { return } inFlightProbe = nil - storedCapability = probed + if let probed { + probeState = .available(probed) + } else { + probeState = .unavailable + } } } + /// Retry a previously unavailable probe at the foreground lifecycle edge. + /// Ordinary artwork requests call ``refresh()`` and therefore reuse the + /// cached unavailable result instead of repeatedly probing older servers. + func retryUnavailable() async { + lock.withLock { + if case .unavailable = probeState { + probeState = .unknown + } + } + await refresh() + } + + /// Server-advertised route template for a textless portrait poster when + /// the requested catalogue type is explicitly supported. A nil value means + /// the server predates the contract or the item should use normal artwork. + static func textlessPosterEndpoint( + capability: ImageSizeCapabilityResponse?, + for contentType: String + ) -> String? { + guard let textlessPoster = capability?.textlessPoster, + !textlessPoster.endpoint.isEmpty, + textlessPoster.supportedTypes.contains(where: { + $0.caseInsensitiveCompare(contentType) == .orderedSame + }) + else { return nil } + return textlessPoster.endpoint + } + + func textlessPosterEndpoint(for contentType: String) -> String? { + Self.textlessPosterEndpoint(capability: capability, for: contentType) + } + /// Drop the cached probe so capabilities don't leak across accounts /// or servers. Bumps `generation` first so any in-flight refresh /// discards its result instead of clobbering this reset. func reset() { let task = lock.withLock { () -> Task? in generation &+= 1 - storedCapability = nil + probeState = .unknown let task = inFlightProbe?.task inFlightProbe = nil return task diff --git a/iosApp/iosApp/Networking/Models.swift b/iosApp/iosApp/Networking/Models.swift index 04b31eb3..c6c53a8c 100644 --- a/iosApp/iosApp/Networking/Models.swift +++ b/iosApp/iosApp/Networking/Models.swift @@ -171,6 +171,7 @@ struct SectionItem: Codable, Identifiable, Hashable { let studios: [String]? let networks: [String]? let showStatus: String? + let tagline: String? let overview: String? let itemSource: String? let positionSeconds: Double? @@ -207,6 +208,7 @@ struct SectionItem: Codable, Identifiable, Hashable { studios = try c.decodeIfPresent([String].self, forKey: .studios) networks = try c.decodeIfPresent([String].self, forKey: .networks) showStatus = try c.decodeIfPresent(String.self, forKey: .showStatus) + tagline = try c.decodeIfPresent(String.self, forKey: .tagline) overview = try c.decodeIfPresent(String.self, forKey: .overview) itemSource = try c.decodeIfPresent(String.self, forKey: .itemSource) positionSeconds = try c.decodeIfPresent(Double.self, forKey: .positionSeconds) diff --git a/iosApp/iosApp/Screens/Home/HomeView.swift b/iosApp/iosApp/Screens/Home/HomeView.swift index d7498388..778b5cb0 100644 --- a/iosApp/iosApp/Screens/Home/HomeView.swift +++ b/iosApp/iosApp/Screens/Home/HomeView.swift @@ -4,9 +4,23 @@ extension Notification.Name { static let homeSectionsShouldRefresh = Notification.Name("homeSectionsShouldRefresh") } -/// Main home screen. iOS/macOS render resume-first section rows on a flat -/// background; tvOS uses the Skyline focus marquee (§5.4) — a passive -/// billboard previewing whichever card holds focus. +/// Keeps audiobook containers out of the video player. Home section items do +/// not carry the part metadata used by audiobook playback, so the hero opens +/// details and lets the existing audiobook flow resolve the correct part. +func dispatchFeaturedHeroPlay( + _ item: SectionItem, + onVideoPlay: (SectionItem) -> Void, + onInfo: (SectionItem) -> Void +) { + if item.isAudiobook { + onInfo(item) + } else { + onVideoPlay(item) + } +} + +/// Main home screen. iOS promotes the server's featured section into a +/// full-bleed spotlight, while tvOS keeps its existing Skyline focus marquee. struct HomeView: View { var homeFocusRequest: Int = 0 /// tvOS-only: whether the custom top menu holds focus. Deferred entry @@ -17,6 +31,7 @@ struct HomeView: View { @State private var viewModel = HomeViewModel() #if !os(tvOS) + @State private var navPreferences = AppNavPreferences.shared @State private var currentProfile: UserProfile? @State private var homeScrollOffset: CGFloat = 0 @State private var isRefreshing = false @@ -30,6 +45,7 @@ struct HomeView: View { /// so the logo + action icons sit comfortably below the Dynamic Island /// rather than crowding it (matching Plex's tight-but-relaxed top spacing). private let headerTopInset: CGFloat = 4 + private let featuredHeaderLift: CGFloat = -19 /// Gap between the bottom of the floating header and the first content row. /// A touch larger than the inter-section spacing so the header reads as a /// distinct band above the rows. @@ -94,7 +110,7 @@ struct HomeView: View { .ignoresSafeArea() Group { - if !displayedSections.isEmpty { + if hasHomeContent { scrollContent } else if let error = viewModel.error { ErrorView(state: error, onRetry: { Task { await viewModel.loadSections() } }) @@ -115,6 +131,7 @@ struct HomeView: View { SidebarToggleButton() SiloWordmarkView(width: 72) + .offset(y: -2) Spacer(minLength: 8) @@ -123,7 +140,7 @@ struct HomeView: View { // (matching Plex's top-right icon row). HStack(spacing: ContinuumTheme.topBarIconSpacing) { #if os(iOS) - SiloControlModeButton(controller: siloControl) { + SiloControlModeButton(controller: siloControl, usesGlassCircle: true) { isShowingControlPicker = true } #endif @@ -137,7 +154,8 @@ struct HomeView: View { router.switchProfile() }, onSwitchServer: { router.navigate(to: .serverList) }, - onSignOut: { router.signOutAndReset() } + onSignOut: { router.signOutAndReset() }, + usesGlassCircles: true ) } } @@ -146,6 +164,9 @@ struct HomeView: View { .padding(.top, headerTopInset) #endif .padding(.bottom, ContinuumTheme.smallPadding) + #if os(iOS) + .offset(y: featuredHeaderLift) + #endif .background { homeHeaderChrome .opacity(headerChromeOpacity) @@ -173,6 +194,7 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) #endif .task { + navPreferences.refresh() await viewModel.loadSections() await loadCurrentProfile() } @@ -203,11 +225,42 @@ struct HomeView: View { GeometryReader { geometry in ScrollView(.vertical, showsIndicators: false) { LazyVStack(alignment: .leading, spacing: HomeFeedMetrics.sectionSpacing) { - // No hero — reserve runway for the floating Home header so - // the first row doesn't slide under the status-bar chrome. - Color.clear - .frame(height: topRunwaySpacing(topSafeAreaInset: geometry.safeAreaInsets.top)) - .id(HomeFocusTarget.topSpacer) + #if os(iOS) + if navPreferences.showFeaturedHero, + let featured = viewModel.featuredSection { + MobileFeaturedHero( + items: featured.items, + usesCardLayout: usesFeaturedHeroCardLayout, + onPlay: playFeaturedItem, + onInfo: { navigateToDetail($0.contentId) }, + loadTextlessPoster: { contentID, contentType in + try await ContinuumAPI.shared.textlessPoster( + contentId: contentID, + contentType: contentType + ) + } + ) + // The card begins below the separate status/header band + // instead of painting behind the logo and actions. + .padding( + .top, + usesFeaturedHeroCardLayout + ? featuredHeaderRunwaySpacing( + topSafeAreaInset: geometry.safeAreaInsets.top + ) + : 0 + ) + // Keep the timer dots clear of Continue Watching while + // retaining the tight, seamless transition into the + // first row. + .padding(.bottom, -(HomeFeedMetrics.sectionSpacing - 14)) + .id(HomeFocusTarget.featured) + } else { + topRunway(topSafeAreaInset: geometry.safeAreaInsets.top) + } + #else + topRunway(topSafeAreaInset: geometry.safeAreaInsets.top) + #endif ForEach(displayedSections) { section in HomeFeedRow( @@ -230,9 +283,16 @@ struct HomeView: View { homeScrollOffset = max(0, newValue) } } + + private func topRunway(topSafeAreaInset: CGFloat) -> some View { + Color.clear + .frame(height: topRunwaySpacing(topSafeAreaInset: topSafeAreaInset)) + .id(HomeFocusTarget.topSpacer) + } #endif private enum HomeFocusTarget: Hashable { + case featured case topSpacer case row(String) } @@ -240,9 +300,28 @@ struct HomeView: View { /// Rows for the vertical list, in server Home order after filtering empty /// and featured sections. Recommendations stay in the For You tab. private var displayedSections: [ResolvedSection] { + #if os(macOS) + return viewModel.sections.filter { !$0.items.isEmpty } + #else return viewModel.regularSections + #endif + } + + private var hasHomeContent: Bool { + #if os(iOS) + return (navPreferences.showFeaturedHero && viewModel.featuredSection != nil) + || !displayedSections.isEmpty + #else + return !displayedSections.isEmpty + #endif } + #if os(iOS) + private var usesFeaturedHeroCardLayout: Bool { + navPreferences.useFeaturedHeroCards + } + #endif + #if !os(tvOS) private var headerChromeOpacity: Double { let progress = min(max(homeScrollOffset / chromeFadeDistance, 0), 1) @@ -318,6 +397,24 @@ struct HomeView: View { router.navigate(to: .itemDetail(contentId: contentId)) } + #if os(iOS) + private func playFeaturedItem(_ item: SectionItem) { + dispatchFeaturedHeroPlay( + item, + onVideoPlay: { playableItem in + router.presentPlayer( + contentId: playableItem.contentId, + resumePosition: playableItem.positionSeconds, + returnToContentId: playableItem.contentId, + posterURL: playableItem.posterUrl, + backdropURL: playableItem.backdropUrl + ) + }, + onInfo: { navigateToDetail($0.contentId) } + ) + } + #endif + private func dismissContinueWatching(_ item: SectionItem) { Task { await viewModel.dismissContinueWatchingItem(item) @@ -345,5 +442,14 @@ struct HomeView: View { #endif return runway } + + #if os(iOS) + private func featuredHeaderRunwaySpacing(topSafeAreaInset: CGFloat) -> CGFloat { + topSafeAreaInset + + headerTopInset + + (ContinuumTheme.topBarIconHitSize * 2) + + 7 + } + #endif #endif } diff --git a/iosApp/iosApp/Screens/Home/HomeViewModel.swift b/iosApp/iosApp/Screens/Home/HomeViewModel.swift index d68a445f..f5d85cb8 100644 --- a/iosApp/iosApp/Screens/Home/HomeViewModel.swift +++ b/iosApp/iosApp/Screens/Home/HomeViewModel.swift @@ -35,11 +35,31 @@ class HomeViewModel { } } - /// Sections for Home in server order, filtered to non-empty rows. - /// `featured` sections render as ordinary rows in their server position — - /// Apple Home has no separate hero surface. + /// The server's top Home row becomes the phone hero only when that exact + /// row is non-empty and marked featured. A featured row farther down must + /// never jump ahead of rows placed above it in the web admin order. + var featuredSection: ResolvedSection? { + guard let firstSection = sections.first, + firstSection.isFeatured, + !firstSection.items.isEmpty else { return nil } + return firstSection + } + + /// Sections for Home in server order. iOS promotes only the top row when + /// it qualifies, so featured sections farther down remain ordinary rows. + /// tvOS suppresses every featured row because its existing hero already + /// owns that content. var regularSections: [ResolvedSection] { - sections.filter { !$0.items.isEmpty } + #if os(iOS) + guard let heroSection = featuredSection else { + return sections.filter { !$0.items.isEmpty } + } + return sections.filter { !$0.items.isEmpty && $0.id != heroSection.id } + #elseif os(tvOS) + return sections.filter { !$0.isFeatured && !$0.items.isEmpty } + #else + return sections.filter { !$0.items.isEmpty } + #endif } init( diff --git a/iosApp/iosApp/Screens/Home/iOS/MobileFeaturedHero.swift b/iosApp/iosApp/Screens/Home/iOS/MobileFeaturedHero.swift new file mode 100644 index 00000000..b3b325f4 --- /dev/null +++ b/iosApp/iosApp/Screens/Home/iOS/MobileFeaturedHero.swift @@ -0,0 +1,581 @@ +#if os(iOS) +import SwiftUI + +/// Server-driven Home cards for iPhone and iPad. The featured section is +/// rendered once here and removed from the rows below. +struct MobileFeaturedHero: View { + let items: [SectionItem] + let usesCardLayout: Bool + let onPlay: (SectionItem) -> Void + let onInfo: (SectionItem) -> Void + let loadTextlessPoster: @Sendable (String, String) async throws -> String? + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var currentIndex: Int? + @State private var lastValidIndex = 0 + @State private var isPagerIdle = true + @State private var autoAdvanceProgress: CGFloat = 0 + @State private var textlessPosterURLs: [String: String] = [:] + @State private var unavailableTextlessPosters: Set = [] + @State private var glowTints: [String: Color] = [:] + + private struct AutoAdvanceKey: Hashable { + let itemIDs: [String] + let currentIndex: Int? + let isPagerIdle: Bool + } + + private struct RenderedCard: Identifiable { + let id: Int + let logicalIndex: Int + let item: SectionItem + } + + /// Duplicate the trailing/leading cards at opposite ends so the carousel + /// wraps by one ordinary page instead of animating across the whole list. + private var renderedCards: [RenderedCard] { + guard items.count > 1 else { + return items.enumerated().map { + RenderedCard(id: $0.offset, logicalIndex: $0.offset, item: $0.element) + } + } + + var cards = [RenderedCard(id: 0, logicalIndex: items.count - 1, item: items[items.count - 1])] + cards.append(contentsOf: items.enumerated().map { + RenderedCard(id: $0.offset + 1, logicalIndex: $0.offset, item: $0.element) + }) + cards.append(RenderedCard(id: items.count + 1, logicalIndex: 0, item: items[0])) + return cards + } + + private var heroHeight: CGFloat { + min(max(PlatformScreen.mainBounds.height * 0.61, 500), 620) + } + + private let indicatorHeight: CGFloat = 23 + + var body: some View { + VStack(spacing: 0) { + GeometryReader { geometry in + let heroWidth = usesCardLayout + ? min(max(geometry.size.width - 24, 280), 620) + : geometry.size.width + let pagingInset = usesCardLayout + ? max((geometry.size.width - heroWidth) / 2, 0) + : 0 + + ScrollView(.horizontal) { + LazyHStack(spacing: usesCardLayout ? 12 : 0) { + ForEach(renderedCards) { card in + Group { + if usesCardLayout { + ZStack { + cardGlow(for: card.item) + + spotlight(card.item, heroWidth: heroWidth) + .frame(width: heroWidth, height: heroHeight) + .clipShape(cardShape) + .overlay { + cardShape.stroke( + Color.white.opacity(0.09), + lineWidth: 0.75 + ) + } + } + .contentShape(cardShape) + } else { + spotlight(card.item, heroWidth: heroWidth) + .frame(width: heroWidth, height: heroHeight) + .contentShape(Rectangle()) + } + } + .frame(width: heroWidth, height: heroHeight) + .onTapGesture { onInfo(card.item) } + .id(card.id) + } + } + .scrollTargetLayout() + } + .scrollIndicators(.hidden) + .scrollClipDisabled() + // Keep the runway outside the target layout. Padding the + // LazyHStack makes every programmatic target inherit one extra + // inset and leaves automatic advances visibly off-centre. + .contentMargins(.horizontal, pagingInset, for: .scrollContent) + .scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne)) + // An explicit centre anchor makes both timer advances and manual + // gestures settle on one complete card instead of a partial page. + .scrollPosition(id: $currentIndex, anchor: .center) + .onScrollPhaseChange { _, phase in + isPagerIdle = phase == .idle + if phase == .idle { + normalizeSettledPage() + } + } + } + .frame(height: heroHeight) + + if items.count > 1 { + timedPageIndicator + .frame(height: indicatorHeight) + } + } + .frame(height: heroHeight + (items.count > 1 ? indicatorHeight : 0)) + .frame(maxWidth: .infinity) + .background(alignment: .bottom) { + if usesCardLayout { + Rectangle() + .fill( + RadialGradient( + colors: [ + activeGlowTint.opacity(0.52), + activeGlowTint.opacity(0.18), + .clear, + ], + center: .top, + startRadius: 0, + endRadius: 260 + ) + ) + .frame(height: 230) + .offset(y: 120) + .blur(radius: 26) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.45), value: activeContentID) + } + } + .background(Color.continuumBackground) + .task { + seedCurrentIndex() + } + .task(id: currentItemID) { + await loadTextlessArtworkAroundCurrentCard() + } + .task(id: activeArtworkURL) { + guard usesCardLayout else { return } + await loadActiveGlowTint() + } + .task( + id: AutoAdvanceKey( + itemIDs: items.map(\.contentId), + currentIndex: currentIndex, + isPagerIdle: isPagerIdle + ) + ) { + var resetTransaction = Transaction() + resetTransaction.disablesAnimations = true + withTransaction(resetTransaction) { + autoAdvanceProgress = 0 + } + + guard items.count > 1 else { return } + guard isPagerIdle else { return } + + await Task.yield() + withAnimation(reduceMotion ? nil : .linear(duration: 10)) { + autoAdvanceProgress = 1 + } + + do { + try await Task.sleep(for: .seconds(10)) + } catch { + return + } + guard !Task.isCancelled, isPagerIdle else { return } + + let nextIndex = min((currentIndex ?? lastValidIndex) + 1, items.count + 1) + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.55)) { + currentIndex = nextIndex + } + } + .onChange(of: items.map(\.contentId)) { _, _ in + seedCurrentIndex() + } + .onChange(of: currentIndex) { _, newIndex in + guard let newIndex, renderedCards.indices.contains(newIndex) else { return } + lastValidIndex = newIndex + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Featured") + } + + private var timedPageIndicator: some View { + let activeIndex = logicalIndex(forPage: currentIndex ?? lastValidIndex) + let progress = min(max(autoAdvanceProgress, 0), 1) + + return HStack(spacing: 8) { + ForEach(items.indices, id: \.self) { index in + if index == activeIndex { + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.28)) + Capsule() + .fill(Color.white.opacity(0.94)) + .frame(width: max(7, 32 * progress)) + } + .frame(width: 32, height: 7) + .clipped() + .accessibilityLabel("Featured item \(index + 1) of \(items.count)") + } else { + Circle() + .fill(Color.white.opacity(0.38)) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } + } + } + .frame(maxWidth: .infinity) + .animation(nil, value: activeIndex) + .accessibilityElement(children: .contain) + } + + private var cardShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 14, style: .continuous) + } + + private var activeContentID: String { + guard !items.isEmpty else { return "" } + return items[logicalIndex(forPage: currentIndex ?? lastValidIndex)].contentId + } + + private var activeGlowTint: Color { + glowTints[activeContentID] ?? .clear + } + + private var activeArtworkURL: String? { + guard !items.isEmpty else { return nil } + return preferredArtworkURL( + for: items[logicalIndex(forPage: currentIndex ?? lastValidIndex)] + ) + } + + private func loadActiveGlowTint() async { + guard let artwork = activeArtworkURL, + let url = URL(string: artwork) else { return } + if let cached = HeroBackdropPalette.cachedTint(for: url) { + glowTints[activeContentID] = cached + } else if let tint = await HeroBackdropPalette.tintColor(for: url) { + withAnimation(.easeInOut(duration: 0.35)) { + glowTints[activeContentID] = tint + } + } + } + + @ViewBuilder + private func cardGlow(for item: SectionItem) -> some View { + if let artwork = preferredArtworkURL(for: item), + let url = URL(string: artwork) { + ZStack { + cardShape + .fill(Color.black.opacity(0.88)) + .blur(radius: 30) + .scaleEffect(1.04) + + cardShape + .fill((glowTints[item.contentId] ?? .clear).opacity(0.56)) + .blur(radius: 24) + .scaleEffect(1.025) + } + .allowsHitTesting(false) + .task(id: artwork) { + if let cached = HeroBackdropPalette.cachedTint(for: url) { + glowTints[item.contentId] = cached + } else if let tint = await HeroBackdropPalette.tintColor(for: url) { + withAnimation(.easeInOut(duration: 0.35)) { + glowTints[item.contentId] = tint + } + } + } + } + } + + private func seedCurrentIndex() { + guard !items.isEmpty else { + currentIndex = nil + lastValidIndex = 0 + return + } + let defaultIndex = items.count > 1 ? 1 : 0 + let candidate = currentIndex ?? defaultIndex + let seededIndex = renderedCards.indices.contains(candidate) ? candidate : defaultIndex + lastValidIndex = seededIndex + currentIndex = seededIndex + } + + private func normalizeSettledPage() { + guard !items.isEmpty else { return } + var page = currentIndex ?? lastValidIndex + if items.count > 1 { + if page == 0 { + page = items.count + } else if page == items.count + 1 { + page = 1 + } + } else { + page = 0 + } + + lastValidIndex = page + guard currentIndex != page else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + currentIndex = page + } + } + + private func logicalIndex(forPage page: Int) -> Int { + guard items.count > 1 else { return 0 } + if page <= 0 { return items.count - 1 } + if page >= items.count + 1 { return 0 } + return page - 1 + } + + private var currentItemID: String? { + guard !items.isEmpty else { return nil } + return items[logicalIndex(forPage: currentIndex ?? lastValidIndex)].contentId + } + + /// Fetch the visible card and its next neighbour. Keeping this cache local + /// to Home avoids refetches while the carousel loops but naturally drops it + /// on profile/server changes when the Home view is rebuilt. + private func loadTextlessArtworkAroundCurrentCard() async { + guard !items.isEmpty else { return } + let index = logicalIndex(forPage: currentIndex ?? lastValidIndex) + let indexes = items.count > 1 ? [index, (index + 1) % items.count] : [index] + + for candidateIndex in indexes { + let candidate = items[candidateIndex] + let contentID = candidate.contentId + guard textlessPosterURLs[contentID] == nil, + !unavailableTextlessPosters.contains(contentID) else { continue } + + do { + if let url = try await loadTextlessPoster(contentID, candidate.type) { + textlessPosterURLs[contentID] = url + } else { + unavailableTextlessPosters.insert(contentID) + } + } catch { + if Task.isCancelled { return } + // A transient request failure remains retryable when the + // carousel next visits this item. + continue + } + } + } + + private func spotlight(_ item: SectionItem, heroWidth: CGFloat) -> some View { + ZStack(alignment: .bottom) { + artwork(for: item, heroWidth: heroWidth) + + LinearGradient( + stops: [ + .init(color: .black.opacity(0.12), location: 0), + .init(color: .clear, location: 0.30), + .init(color: .black.opacity(0.18), location: 0.52), + .init(color: .black.opacity(0.74), location: 0.76), + .init(color: .black.opacity(0.96), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + editorialContent(for: item) + .padding(.horizontal, 18) + .padding(.bottom, 18) + } + .background(Color.continuumSurface) + } + + @ViewBuilder + private func artwork(for item: SectionItem, heroWidth: CGFloat) -> some View { + if let url = preferredArtworkURL(for: item) { + AsyncImageView( + url: url, + thumbhash: item.posterThumbhash ?? item.backdropThumbhash, + targetSize: CGSize(width: heroWidth, height: heroHeight), + contentMode: .fill + ) + .frame(width: heroWidth, height: heroHeight) + .clipped() + .transition(.opacity.animation(.easeInOut(duration: 0.35))) + } else { + Color.continuumSurface + } + } + + private func editorialContent(for item: SectionItem) -> some View { + VStack(alignment: .center, spacing: 9) { + heroTitle(for: item) + + Text(editorialQuote(for: item)) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.94)) + .lineLimit(1) + .minimumScaleFactor(0.85) + .multilineTextAlignment(.center) + + if !metadata(for: item).isEmpty { + metadataRow(for: item) + } + + HStack(spacing: 10) { + Button { + onPlay(item) + } label: { + Label(playLabel(for: item), systemImage: "play.fill") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background( + .white, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) + + Button { + onInfo(item) + } label: { + Label("More Info", systemImage: "info.circle") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 46) + .siloGlass( + in: RoundedRectangle(cornerRadius: 8, style: .continuous), + tint: Color.black.opacity(0.18), + interactive: true + ) + } + .buttonStyle(.plain) + } + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + private func heroTitle(for item: SectionItem) -> some View { + if let logo = item.logoUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + !logo.isEmpty { + AsyncImageView( + url: logo, + contentMode: .fit, + placeholderStyle: .clear + ) + .frame(width: 220, height: 76, alignment: .center) + .accessibilityLabel(item.title) + } else { + Text(item.title) + .font(.system(size: 36, weight: .black, design: .rounded)) + .tracking(-1) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.72) + .multilineTextAlignment(.center) + } + } + + private func metadataRow(for item: SectionItem) -> some View { + Text(metadata(for: item).joined(separator: " · ")) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.72)) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .center) + } + + private func preferredArtworkURL(for item: SectionItem) -> String? { + if let textless = textlessPosterURLs[item.contentId], !textless.isEmpty { + return textless + } + let poster = item.posterUrl?.trimmingCharacters(in: .whitespacesAndNewlines) + if let poster, !poster.isEmpty { return poster } + let backdrop = item.backdropUrl?.trimmingCharacters(in: .whitespacesAndNewlines) + return backdrop?.isEmpty == false ? backdrop : nil + } + + private func editorialQuote(for item: SectionItem) -> String { + if let tagline = item.tagline?.trimmingCharacters(in: .whitespacesAndNewlines), + !tagline.isEmpty, + let quote = shortQuote(tagline) { + return quote + } + if let overview = item.overview?.trimmingCharacters(in: .whitespacesAndNewlines), + !overview.isEmpty { + let punctuation = CharacterSet(charactersIn: ".!?") + if let end = overview.rangeOfCharacter(from: punctuation)?.lowerBound, + let quote = shortQuote(String(overview[...end])) { + return quote + } + if let quote = shortQuote(overview) { + return quote + } + } + + // Some libraries do not have a provider tagline or overview. Keep the + // hero's editorial rhythm intact without inventing title-specific copy. + return "Ready when you are." + } + + /// A hero tagline must read as a compact pull quote. Prefer a complete + /// clause; otherwise keep at most six whole words and never show a clipped + /// ellipsis in this surface. + private func shortQuote(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= 46 { return trimmed } + + if let clauseEnd = trimmed.firstIndex(where: { $0 == "," || $0 == ";" }) { + let clause = String(trimmed[..= 8, clause.count <= 46, wordCount >= 3 { + return clause + } + } + + var words: [Substring] = [] + for word in trimmed.split(separator: " ").prefix(6) { + let candidate = (words + [word]).joined(separator: " ") + if candidate.count > 46 { break } + words.append(word) + } + let compact = words.joined(separator: " ") + .trimmingCharacters(in: .punctuationCharacters.union(.whitespaces)) + return compact.isEmpty ? nil : compact + } + + private func metadata(for item: SectionItem) -> [String] { + var result: [String] = [] + if let rating = item.ratingImdb ?? item.ratingTmdb, rating > 0 { + result.append(String(format: "★ %.1f", rating)) + } + result.append(contentsOf: (item.genres ?? []).filter { !$0.isEmpty }.prefix(2)) + if let runtime = item.runtime, runtime > 0 { + result.append(formatRuntime(runtime)) + } + if let rating = item.contentRating?.trimmingCharacters(in: .whitespacesAndNewlines), + !rating.isEmpty { + result.append(rating.uppercased()) + } + return result + } + + private func formatRuntime(_ minutes: Int) -> String { + guard minutes >= 60 else { return "\(minutes)m" } + let remainder = minutes % 60 + return remainder == 0 ? "\(minutes / 60)h" : "\(minutes / 60)h \(remainder)m" + } + + private func playLabel(for item: SectionItem) -> String { + guard let position = item.positionSeconds, position > 60 else { return "Play" } + return "Resume" + } +} +#endif diff --git a/iosApp/iosApp/Screens/Settings/InterfaceCustomizationView.swift b/iosApp/iosApp/Screens/Settings/InterfaceCustomizationView.swift index c6dcaf48..ed70e472 100644 --- a/iosApp/iosApp/Screens/Settings/InterfaceCustomizationView.swift +++ b/iosApp/iosApp/Screens/Settings/InterfaceCustomizationView.swift @@ -133,12 +133,40 @@ func offsetPrimaryMenuEditorItem( /// outside the reorderable list, matching the cross-client contract. struct InterfaceCustomizationView: View { @State private var preferences = UICustomizationPreferences.shared + @State private var navPreferences = AppNavPreferences.shared @State private var registry = ServerRegistry.shared @State private var librarySnapshot = MainTabLibrarySnapshot.cachedForCurrentAuthority() @Environment(\.scenePhase) private var scenePhase var body: some View { List { + #if os(iOS) + Section { + Toggle( + "Show Featured Hero", + isOn: Binding( + get: { navPreferences.showFeaturedHero }, + set: { navPreferences.setShowFeaturedHero($0) } + ) + ) + Toggle( + "Card Layout", + isOn: Binding( + get: { navPreferences.useFeaturedHeroCards }, + set: { navPreferences.setUseFeaturedHeroCards($0) } + ) + ) + .disabled(!navPreferences.showFeaturedHero) + } header: { + Text("Home") + } footer: { + Text( + "Turn off Card Layout for a full-width hero with no card, border, " + + "or side gutter. Hiding the hero moves your other Home rows up." + ) + } + #endif + if let message = preferences.capabilityMessage { Section { Label(message, systemImage: "server.rack") @@ -307,6 +335,7 @@ struct InterfaceCustomizationView: View { .continuumGroupedListStyle() .navigationTitle("Interface") .task { + navPreferences.refresh() await preferences.refresh() } .task(id: currentLibraryAuthority) { diff --git a/iosApp/iosApp/Shared/ImageSizeSelection.swift b/iosApp/iosApp/Shared/ImageSizeSelection.swift index 3a6c0efe..73a403ba 100644 --- a/iosApp/iosApp/Shared/ImageSizeSelection.swift +++ b/iosApp/iosApp/Shared/ImageSizeSelection.swift @@ -9,6 +9,32 @@ struct ImageSizeCapabilityResponse: Codable, Equatable { let sizes: [String] let widths: [String: [String: Int]] let originalMaxWidthPx: Int + let textlessPoster: TextlessPosterCapability? + + init( + schemaVersion: Int, + param: String, + sizes: [String], + widths: [String: [String: Int]], + originalMaxWidthPx: Int, + textlessPoster: TextlessPosterCapability? = nil + ) { + self.schemaVersion = schemaVersion + self.param = param + self.sizes = sizes + self.widths = widths + self.originalMaxWidthPx = originalMaxWidthPx + self.textlessPoster = textlessPoster + } +} + +struct TextlessPosterCapability: Codable, Equatable { + let endpoint: String + let supportedTypes: [String] +} + +struct TextlessPosterResponse: Codable, Equatable { + let posterUrl: String? } enum ImageSizeSelection { diff --git a/iosApp/iosApp/Startup/StartupContentPrefetcher.swift b/iosApp/iosApp/Startup/StartupContentPrefetcher.swift index 733023e7..5501c2ff 100644 --- a/iosApp/iosApp/Startup/StartupContentPrefetcher.swift +++ b/iosApp/iosApp/Startup/StartupContentPrefetcher.swift @@ -627,15 +627,21 @@ enum StartupContentPrefetcher { urls.append(url) } - // No client renders a featured hero anymore — featured sections show - // as ordinary rows. Entry lands on the first card of the first content - // row. Warm that row's logo + art first (so a cold start paints a - // finished first row), then the rest. (The first row's logo + backdrop - // are sized for the tvOS focus marquee; on other platforms only - // posters/episode stills render, so those two are speculative but - // harmless.) + // iOS promotes the featured section into the Home spotlight. Warm its + // first logo and backdrops before row artwork so cold entry paints the + // hero immediately. tvOS filters this section from its rows, but the + // small speculative fetch is harmless there. let contentSections = response.sections.filter { !$0.items.isEmpty } - if let firstRow = contentSections.first { + if let featured = contentSections.first(where: { $0.isFeatured }) { + append(featured.items.first?.logoUrl) + for item in featured.items { + append(item.backdropUrl ?? item.posterUrl) + if urls.count >= maxHomeArtworkURLs { break } + } + } + + let rowSections = contentSections.filter { !$0.isFeatured } + if let firstRow = rowSections.first { append(firstRow.items.first?.logoUrl) for item in firstRow.items { if episodeSectionTypes.contains(firstRow.sectionType) { @@ -649,7 +655,7 @@ enum StartupContentPrefetcher { if urls.count >= maxHomeArtworkURLs { break } } } - for section in contentSections.dropFirst() { + for section in rowSections.dropFirst() { for item in section.items { if episodeSectionTypes.contains(section.sectionType) { append(item.backdropUrl ?? item.posterUrl) @@ -670,7 +676,7 @@ enum StartupContentPrefetcher { // fading up from the black background once sampling finishes. Other // platforms render no marquee, so skip the fetch + sampling there. #if os(tvOS) - if let firstBackdrop = normalizedURL(from: contentSections.first?.items.first?.backdropUrl) { + if let firstBackdrop = normalizedURL(from: rowSections.first?.items.first?.backdropUrl) { Task { _ = await HeroBackdropPalette.tintColor(for: firstBackdrop) } } #endif