From 7a1af0241847ec69dd76cb27f990d540b62119a0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 26 May 2026 06:43:34 +0200 Subject: [PATCH] Add card style option for Home shortcut cards Adds a Style picker (Plain / Accent / Colorful) to the Customize Home sheet, shown when the shortcuts layout is Cards. Defaults to Plain. - Plain: current look (light accent tint + accent border) - Accent: solid accent fill, white icon/text - Colorful: solid fill with a fixed color per shortcut type The filled styles use a Reminders-style layout (icon top-leading, count top-trailing, title anchored to the bottom) with a subtle gradient sheen over the fill color. Cards without a meaningful count (Open Link, Remote, Subscriptions, instance content, media sources) hide the number; Remote shows its status dot in the top-right slot instead. Persisted via SettingsKey.homeShortcutCardStyle, mirroring the existing homeShortcutLayout accessor. --- Yattee/Core/Settings/SettingsKey.swift | 3 +- .../Core/Settings/SettingsManager+Home.swift | 15 ++ Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 44 ++++++ Yattee/Views/Home/HomeItem.swift | 56 +++++++ Yattee/Views/Home/HomeSettingsView.swift | 12 ++ Yattee/Views/Home/HomeShortcutCardView.swift | 148 ++++++++++++++++-- Yattee/Views/Home/HomeView.swift | 39 +++-- 8 files changed, 294 insertions(+), 25 deletions(-) diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 1a9ac0de..c393b48f 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -64,6 +64,7 @@ enum SettingsKey: String, CaseIterable { case homeShortcutOrder case homeShortcutVisibility case homeShortcutLayout + case homeShortcutCardStyle case homeSectionOrder case homeSectionVisibility case homeSectionItemsLimit @@ -135,7 +136,7 @@ enum SettingsKey: String, CaseIterable { switch self { case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .macPlayerMode, .listStyle, // Home layout — different UI paradigms per platform - .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, + .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle, .homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout, // Top Shelf — tvOS only .topShelfSections, diff --git a/Yattee/Core/Settings/SettingsManager+Home.swift b/Yattee/Core/Settings/SettingsManager+Home.swift index d8e3b606..5c5fb99f 100644 --- a/Yattee/Core/Settings/SettingsManager+Home.swift +++ b/Yattee/Core/Settings/SettingsManager+Home.swift @@ -95,6 +95,21 @@ extension SettingsManager { } } + /// Visual style for home shortcut cards (plain, accent-filled, or colorful). Default is plain. + var homeShortcutCardStyle: HomeShortcutCardStyle { + get { + if let cached = _homeShortcutCardStyle { return cached } + guard let rawValue = string(for: .homeShortcutCardStyle) else { + return .plain + } + return HomeShortcutCardStyle(rawValue: rawValue) ?? .plain + } + set { + _homeShortcutCardStyle = newValue + set(newValue.rawValue, for: .homeShortcutCardStyle) + } + } + /// Layout mode for home sections (list or grid). Default is list on iOS/macOS, grid on tvOS. var homeSectionLayout: HomeSectionLayout { get { diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 56e43382..c723840d 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -108,6 +108,7 @@ final class SettingsManager { var _homeShortcutOrder: [HomeShortcutItem]? var _homeShortcutVisibility: [HomeShortcutItem: Bool]? var _homeShortcutLayout: HomeShortcutLayout? + var _homeShortcutCardStyle: HomeShortcutCardStyle? var _homeSectionOrder: [HomeSectionItem]? var _homeSectionVisibility: [HomeSectionItem: Bool]? var _homeSectionItemsLimit: Int? @@ -463,6 +464,7 @@ final class SettingsManager { _homeShortcutOrder = nil _homeShortcutVisibility = nil _homeShortcutLayout = nil + _homeShortcutCardStyle = nil _homeSectionOrder = nil _homeSectionVisibility = nil _homeSectionItemsLimit = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 73a143b8..10463956 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -4192,6 +4192,50 @@ } } }, + "home.settings.shortcuts.style" : { + "comment" : "Label for shortcuts card style picker in library settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style" + } + } + } + }, + "home.shortcuts.style.accent" : { + "comment" : "Accent card style option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accent" + } + } + } + }, + "home.shortcuts.style.colorful" : { + "comment" : "Colorful card style option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colorful" + } + } + } + }, + "home.shortcuts.style.plain" : { + "comment" : "Plain card style option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plain" + } + } + } + }, "home.settings.sourceDisabled" : { }, diff --git a/Yattee/Views/Home/HomeItem.swift b/Yattee/Views/Home/HomeItem.swift index d6a25e57..cdf8a58f 100644 --- a/Yattee/Views/Home/HomeItem.swift +++ b/Yattee/Views/Home/HomeItem.swift @@ -30,6 +30,26 @@ enum HomeShortcutLayout: String, CaseIterable, Sendable { } } +// MARK: - Home Shortcut Card Style + +/// Visual style for shortcut cards in the Home view (cards layout only). +enum HomeShortcutCardStyle: String, CaseIterable, Sendable { + /// Current look: light accent tint background with an accent border. + case plain + /// Solid fill in the user's accent color, white icon/text. + case accent + /// Solid fill with a fixed color per shortcut type, white icon/text (Reminders-style). + case colorful + + var displayName: LocalizedStringKey { + switch self { + case .plain: return "home.shortcuts.style.plain" + case .accent: return "home.shortcuts.style.accent" + case .colorful: return "home.shortcuts.style.colorful" + } + } +} + // MARK: - Home Section Layout /// Layout mode for the configurable home sections (Continue Watching, Feed, etc.). @@ -190,6 +210,42 @@ enum HomeShortcutItem: Codable, Hashable, Identifiable, Sendable { } } + /// Fixed color for this shortcut used by the "colorful" card style. + /// Dynamic items (instance content / media sources) derive a stable color + /// from their identifier so multiple items get varied hues. + var cardColor: Color { + switch self { + case .openURL: return .blue + case .remoteControl: return .teal + case .playlists: return .indigo + case .bookmarks: return .pink + case .continueWatching: return .purple + case .history: return .orange + case .downloads: return .green + case .channels: return .red + case .subscriptions: return .cyan + case .mediaSources: return .brown + case .instanceContent(let instanceID, _): + return Self.colorfulPalette[Self.stableIndex(for: instanceID.uuidString, count: Self.colorfulPalette.count)] + case .mediaSource(let sourceID): + return Self.colorfulPalette[Self.stableIndex(for: sourceID.uuidString, count: Self.colorfulPalette.count)] + } + } + + /// Palette used to assign stable colors to dynamic shortcut items. + private static let colorfulPalette: [Color] = [ + .blue, .red, .orange, .pink, .teal, .green, .indigo, .purple, .cyan, .brown + ] + + /// Deterministic (launch-stable) hash of a string into a palette index. + private static func stableIndex(for key: String, count: Int) -> Int { + var hash = 5381 + for byte in key.utf8 { + hash = (hash &* 33) &+ Int(byte) + } + return abs(hash) % max(count, 1) + } + // MARK: - Codable Implementation private enum CodingKeys: String, CodingKey { diff --git a/Yattee/Views/Home/HomeSettingsView.swift b/Yattee/Views/Home/HomeSettingsView.swift index 2c01ecae..9ae64b0b 100644 --- a/Yattee/Views/Home/HomeSettingsView.swift +++ b/Yattee/Views/Home/HomeSettingsView.swift @@ -12,6 +12,7 @@ struct HomeSettingsView: View { // Local state for editing (copied from settings on appear, saved on dismiss) @State private var shortcutLayout: HomeShortcutLayout = .cards + @State private var shortcutCardStyle: HomeShortcutCardStyle = .plain @State private var shortcutOrder: [HomeShortcutItem] = [] @State private var shortcutVisibility: [HomeShortcutItem: Bool] = [:] @State private var sectionOrder: [HomeSectionItem] = [] @@ -73,6 +74,15 @@ struct HomeSettingsView: View { } } .pickerStyle(.segmented) + + // Card style picker (Plain / Accent / Colorful) — only for cards layout + if shortcutLayout == .cards { + Picker(String(localized: "home.settings.shortcuts.style"), selection: $shortcutCardStyle) { + ForEach(HomeShortcutCardStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + } #endif #if os(tvOS) @@ -268,6 +278,7 @@ struct HomeSettingsView: View { let env = appEnvironment else { return } shortcutLayout = settings.homeShortcutLayout + shortcutCardStyle = settings.homeShortcutCardStyle shortcutOrder = settings.homeShortcutOrder shortcutVisibility = settings.homeShortcutVisibility sectionOrder = settings.homeSectionOrder @@ -288,6 +299,7 @@ struct HomeSettingsView: View { private func saveSettings() { guard let settings = settingsManager else { return } settings.homeShortcutLayout = shortcutLayout + settings.homeShortcutCardStyle = shortcutCardStyle settings.homeShortcutOrder = shortcutOrder settings.homeShortcutVisibility = shortcutVisibility settings.homeSectionOrder = sectionOrder diff --git a/Yattee/Views/Home/HomeShortcutCardView.swift b/Yattee/Views/Home/HomeShortcutCardView.swift index 4055956b..bbaf9512 100644 --- a/Yattee/Views/Home/HomeShortcutCardView.swift +++ b/Yattee/Views/Home/HomeShortcutCardView.swift @@ -22,6 +22,11 @@ struct HomeShortcutCardView: View { let title: String let count: Int let subtitle: String + /// Whether to show the count number in the filled (Reminders) layout. + /// Cards without a meaningful count (Open Link, Remote, Subscriptions) hide it. + var showsCount: Bool + /// Fixed color used when the "colorful" card style is active. + var colorfulColor: Color var statusIndicator: StatusIndicator? init( @@ -29,15 +34,49 @@ struct HomeShortcutCardView: View { title: String, count: Int, subtitle: String, + showsCount: Bool = true, + colorfulColor: Color = .accentColor, statusIndicator: StatusIndicator? ) { self.icon = icon self.title = title self.count = count self.subtitle = subtitle + self.showsCount = showsCount + self.colorfulColor = colorfulColor self.statusIndicator = statusIndicator } + // MARK: - Card Style + + private var cardStyle: HomeShortcutCardStyle { + #if os(tvOS) + return .plain + #else + return appEnvironment?.settingsManager.homeShortcutCardStyle ?? .plain + #endif + } + + private var isFilled: Bool { + cardStyle != .plain + } + + private var fillColor: Color { + cardStyle == .colorful ? colorfulColor : accentColor + } + + private var iconColor: Color { + isFilled ? .white : accentColor + } + + private var titleColor: Color { + isFilled ? .white : .primary + } + + private var subtitleColor: Color { + isFilled ? Color.white.opacity(0.85) : .secondary + } + // Platform-specific styling #if os(tvOS) private let iconSize: CGFloat = 36 @@ -70,18 +109,30 @@ struct HomeShortcutCardView: View { #endif } + /// Filled styles (accent/colorful) use a Reminders-style layout: + /// icon top-leading, count top-trailing, title at the bottom. + private var useRemindersLayout: Bool { + #if os(tvOS) + return false + #else + return isFilled + #endif + } + private var hasSubtitle: Bool { !subtitle.isEmpty } var body: some View { Group { - if needsVerticalLayout { + if useRemindersLayout { + remindersLayout + } else if needsVerticalLayout { // Vertical layout for tvOS and accessibility sizes VStack(alignment: .leading, spacing: 8) { Image(systemName: icon) .font(.title3) - .foregroundStyle(accentColor) + .foregroundStyle(iconColor) .frame(width: iconSize) VStack(alignment: .leading, spacing: 2) { @@ -89,7 +140,7 @@ struct HomeShortcutCardView: View { Text(title) .font(titleFont) .fontWeight(.semibold) - .foregroundStyle(.primary) + .foregroundStyle(titleColor) if let statusIndicator { statusIndicator.padding(.leading, 4) } @@ -97,7 +148,7 @@ struct HomeShortcutCardView: View { Text(hasSubtitle ? subtitle : " ") .font(subtitleFont) - .foregroundStyle(.secondary) + .foregroundStyle(subtitleColor) .opacity(hasSubtitle ? 1 : 0) } } @@ -107,7 +158,7 @@ struct HomeShortcutCardView: View { HStack(alignment: .center, spacing: 8) { Image(systemName: icon) .font(.title3) - .foregroundStyle(accentColor) + .foregroundStyle(iconColor) .frame(width: iconSize) VStack(alignment: .leading, spacing: 2) { @@ -115,7 +166,7 @@ struct HomeShortcutCardView: View { Text(title) .font(titleFont) .fontWeight(.semibold) - .foregroundStyle(.primary) + .foregroundStyle(titleColor) .allowsTightening(true) .lineLimit(1) if let statusIndicator { @@ -126,7 +177,7 @@ struct HomeShortcutCardView: View { if hasSubtitle { Text(subtitle) .font(subtitleFont) - .foregroundStyle(.secondary) + .foregroundStyle(subtitleColor) .lineLimit(1) } } @@ -141,11 +192,48 @@ struct HomeShortcutCardView: View { .background(cardBackground) .overlay( RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(accentColor.opacity(0.3), lineWidth: 1) + .strokeBorder(borderColor, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } + /// Reminders-style layout used by the accent/colorful filled styles: + /// icon top-leading, count top-trailing, title anchored to the bottom. + private var remindersLayout: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 4) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(iconColor) + + Spacer(minLength: 4) + + if let statusIndicator { + statusIndicator + } + + if showsCount { + Text(count, format: .number) + .font(.title2) + .fontWeight(.bold) + .monospacedDigit() + .foregroundStyle(titleColor) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + Text(title) + .font(titleFont) + .fontWeight(.semibold) + .foregroundStyle(titleColor) + .lineLimit(1) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, minHeight: remindersMinHeight, alignment: .leading) + } + /// Minimum height for the text content area to ensure consistent card heights private var subtitleMinHeight: CGFloat { #if os(tvOS) @@ -160,11 +248,41 @@ struct HomeShortcutCardView: View { #endif } - private var cardBackground: some ShapeStyle { - #if os(tvOS) - isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3) + /// Minimum content height for the Reminders-style layout so the title sits + /// well below the icon/count row (matches Apple's roomier card proportions). + private var remindersMinHeight: CGFloat { + #if os(macOS) + 64 #else - accentColor.opacity(0.1) + 60 + #endif + } + + @ViewBuilder + private var cardBackground: some View { + #if os(tvOS) + (isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3)) + #else + if isFilled { + // Solid fill plus a subtle top-to-bottom sheen for a gentle gradient. + fillColor.overlay( + LinearGradient( + colors: [Color.white.opacity(0.28), Color.clear, Color.black.opacity(0.10)], + startPoint: .top, + endPoint: .bottom + ) + ) + } else { + accentColor.opacity(0.1) + } + #endif + } + + private var borderColor: Color { + #if os(tvOS) + return accentColor.opacity(0.3) + #else + return isFilled ? .clear : accentColor.opacity(0.3) #endif } } @@ -176,12 +294,16 @@ extension HomeShortcutCardView where StatusIndicator == EmptyView { icon: String, title: String, count: Int, - subtitle: String + subtitle: String, + showsCount: Bool = true, + colorfulColor: Color = .accentColor ) { self.icon = icon self.title = title self.count = count self.subtitle = subtitle + self.showsCount = showsCount + self.colorfulColor = colorfulColor self.statusIndicator = nil } } diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 63775272..3039de33 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -485,7 +485,9 @@ struct HomeView: View { icon: "link", title: String(localized: "home.shortcut.openURL"), count: 0, - subtitle: "" + subtitle: "", + showsCount: false, + colorfulColor: HomeShortcutItem.openURL.cardColor ) } #if os(tvOS) @@ -507,6 +509,8 @@ struct HomeView: View { title: String(localized: "home.shortcut.remoteControl"), count: discoveredDevicesCount, subtitle: "", + showsCount: false, + colorfulColor: HomeShortcutItem.remoteControl.cardColor, statusIndicator: Circle() .fill(isHosting ? Color.green : Color.red) .frame(width: 8, height: 8) @@ -528,7 +532,8 @@ struct HomeView: View { icon: "list.bullet.rectangle", title: String(localized: "home.playlists.title"), count: playlists.count, - subtitle: formatCount(playlists.count, singular: "home.count.playlist", plural: "home.count.playlists") + subtitle: formatCount(playlists.count, singular: "home.count.playlist", plural: "home.count.playlists"), + colorfulColor: HomeShortcutItem.playlists.cardColor ) } #if os(tvOS) @@ -547,7 +552,8 @@ struct HomeView: View { icon: "bookmark", title: String(localized: "home.bookmarks.title"), count: bookmarksCount, - subtitle: formatCount(bookmarksCount, singular: "home.count.bookmark", plural: "home.count.bookmarks") + subtitle: formatCount(bookmarksCount, singular: "home.count.bookmark", plural: "home.count.bookmarks"), + colorfulColor: HomeShortcutItem.bookmarks.cardColor ) } #if os(tvOS) @@ -566,7 +572,8 @@ struct HomeView: View { icon: "play.circle", title: String(localized: "home.shortcut.continueWatching"), count: continueWatchingCount, - subtitle: formatCount(continueWatchingCount, singular: "home.count.video", plural: "home.count.videos") + subtitle: formatCount(continueWatchingCount, singular: "home.count.video", plural: "home.count.videos"), + colorfulColor: HomeShortcutItem.continueWatching.cardColor ) } #if os(tvOS) @@ -585,7 +592,8 @@ struct HomeView: View { icon: "clock", title: String(localized: "home.history.title"), count: historyCount, - subtitle: formatCount(historyCount, singular: "home.count.video", plural: "home.count.videos") + subtitle: formatCount(historyCount, singular: "home.count.video", plural: "home.count.videos"), + colorfulColor: HomeShortcutItem.history.cardColor ) } #if os(tvOS) @@ -606,7 +614,8 @@ struct HomeView: View { icon: "arrow.down.circle", title: String(localized: "home.downloads.title"), count: count, - subtitle: formatCount(count, singular: "home.count.video", plural: "home.count.videos") + subtitle: formatCount(count, singular: "home.count.video", plural: "home.count.videos"), + colorfulColor: HomeShortcutItem.downloads.cardColor ) } .buttonStyle(.plain) @@ -622,7 +631,8 @@ struct HomeView: View { icon: "person.2", title: String(localized: "home.channels.title"), count: channelsCount, - subtitle: formatCount(channelsCount, singular: "home.count.channel", plural: "home.count.channels") + subtitle: formatCount(channelsCount, singular: "home.count.channel", plural: "home.count.channels"), + colorfulColor: HomeShortcutItem.channels.cardColor ) } #if os(tvOS) @@ -641,7 +651,9 @@ struct HomeView: View { icon: "play.square.stack", title: String(localized: "home.subscriptions.title"), count: 0, - subtitle: String(localized: "home.subscriptions.subtitle") + subtitle: String(localized: "home.subscriptions.subtitle"), + showsCount: false, + colorfulColor: HomeShortcutItem.subscriptions.cardColor ) } #if os(tvOS) @@ -663,7 +675,8 @@ struct HomeView: View { icon: "externaldrive.connected.to.line.below", title: "Sources", count: count, - subtitle: count == 1 ? "1 source" : "\(count) sources" + subtitle: count == 1 ? "1 source" : "\(count) sources", + colorfulColor: HomeShortcutItem.mediaSources.cardColor ) } #if os(tvOS) @@ -686,7 +699,9 @@ struct HomeView: View { icon: contentType.icon, title: contentType.localizedTitle, count: 0, - subtitle: instance.displayName + subtitle: instance.displayName, + showsCount: false, + colorfulColor: HomeShortcutItem.instanceContent(instanceID: instanceID, contentType: contentType).cardColor ) } #if os(tvOS) @@ -709,7 +724,9 @@ struct HomeView: View { icon: source.type.systemImage, title: source.name, count: 0, - subtitle: source.type.displayName + subtitle: source.type.displayName, + showsCount: false, + colorfulColor: HomeShortcutItem.mediaSource(sourceID: sourceID).cardColor ) } #if os(tvOS)