From 5e205e4a4cb302bb32417e3dfc23685c5429136b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 19 Apr 2026 15:04:21 +0200 Subject: [PATCH] Use resizable sidebar layout for Subscriptions on macOS Replaces the iOS-style floating channel strip with a dedicated channels column next to the feed, using HSplitView for a native draggable divider. Mirrors the tvOS two-column structure. --- .../MacSubscriptionsSidebarRow.swift | 77 ++++++++++++++++ .../Subscriptions/SubscriptionsView.swift | 91 ++++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 Yattee/Views/Subscriptions/MacSubscriptionsSidebarRow.swift diff --git a/Yattee/Views/Subscriptions/MacSubscriptionsSidebarRow.swift b/Yattee/Views/Subscriptions/MacSubscriptionsSidebarRow.swift new file mode 100644 index 00000000..c50a67aa --- /dev/null +++ b/Yattee/Views/Subscriptions/MacSubscriptionsSidebarRow.swift @@ -0,0 +1,77 @@ +// +// MacSubscriptionsSidebarRow.swift +// Yattee +// +// Compact row used in the macOS Subscriptions sidebar for the +// "All Channels" entry and each subscribed channel. +// + +#if os(macOS) +import SwiftUI +import NukeUI + +struct MacSubscriptionsSidebarRow: View { + let name: String + let avatarURL: URL? + let serverURL: URL? + let authHeader: String? + let channelID: String? + let isAllChannels: Bool + + private let avatarSize: CGFloat = 28 + + private var effectiveAvatarURL: URL? { + guard let channelID else { return nil } + return AvatarURLBuilder.avatarURL( + channelID: channelID, + directURL: avatarURL, + serverURL: serverURL, + size: Int(avatarSize * 2) + ) + } + + var body: some View { + HStack(spacing: 8) { + avatar + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + + Text(name) + .lineLimit(1) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var avatar: some View { + if isAllChannels { + ZStack { + Circle().fill(.quaternary) + Image(systemName: "rectangle.stack.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.secondary) + .padding(avatarSize * 0.25) + } + } else { + LazyImage(request: AvatarURLBuilder.imageRequest(url: effectiveAvatarURL, authHeader: authHeader)) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Circle() + .fill(.quaternary) + .overlay { + Text(String(name.prefix(1))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } +} +#endif diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index a4ccf471..d039dcde 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -221,6 +221,23 @@ struct SubscriptionsView: View { } .padding(.horizontal, 16) .padding(.top, 20) + #elseif os(macOS) + HSplitView { + if subscriptionsLoaded && subscriptions.count > 1 { + macOSChannelsSidebar + .frame(minWidth: 180, idealWidth: 240, maxWidth: 400) + } + + Group { + switch layout { + case .list: + listContent + case .grid: + gridContent + } + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) + } #else Group { switch layout { @@ -239,8 +256,8 @@ struct SubscriptionsView: View { } #endif - // Bottom overlay for filter strip - #if !os(tvOS) + // Bottom overlay for filter strip (iOS only) + #if os(iOS) VStack { Spacer() @@ -311,7 +328,7 @@ struct SubscriptionsView: View { Toggle("viewOptions.hideWatched", isOn: $hideWatched) - #if !os(tvOS) + #if os(iOS) Picker("viewOptions.channelStrip", selection: $channelStripSize) { ForEach(ChannelStripSize.allCases, id: \.self) { size in Text(size.displayName).tag(size) @@ -435,10 +452,14 @@ struct SubscriptionsView: View { } content: { feedContentRows } footer: { + #if os(iOS) // Bottom spacer for channel strip overlay (outside the card) if channelStripSize != .disabled && subscriptions.count > 1 && !isShowingFullScreenError { Color.clear.frame(height: channelStripSize.totalHeight) } + #else + EmptyView() + #endif } } @@ -721,6 +742,70 @@ struct SubscriptionsView: View { } #endif + // MARK: - macOS Channels Sidebar + + #if os(macOS) + private static let macOSAllChannelsTag = "__all__" + + private var macOSSidebarSelection: Binding { + Binding( + get: { selectedChannelID ?? Self.macOSAllChannelsTag }, + set: { newValue in + selectedChannelID = (newValue == Self.macOSAllChannelsTag) ? nil : newValue + } + ) + } + + private var macOSChannelsSidebar: some View { + List(selection: macOSSidebarSelection) { + MacSubscriptionsSidebarRow( + name: String(localized: "subscriptions.allChannels"), + avatarURL: nil, + serverURL: nil, + authHeader: nil, + channelID: nil, + isAllChannels: true + ) + .tag(Self.macOSAllChannelsTag) + .contextMenu { + Button { + appEnvironment?.navigationCoordinator.navigate(to: .manageChannels) + } label: { + Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape") + } + } + + ForEach(sortedSubscriptions, id: \.channelID) { subscription in + MacSubscriptionsSidebarRow( + name: subscription.name, + avatarURL: subscription.avatarURL, + serverURL: yatteeServerURL, + authHeader: yatteeServerAuthHeader, + channelID: subscription.channelID, + isAllChannels: false + ) + .tag(Optional(subscription.channelID)) + .contextMenu { + Button { + appEnvironment?.navigationCoordinator.navigate( + to: .channel(subscription.channelID, subscription.contentSource) + ) + } label: { + Label(String(localized: "subscriptions.goToChannel"), systemImage: "person.circle") + } + Button(role: .destructive) { + unsubscribeChannel(subscription.channelID) + } label: { + Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus") + } + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + #endif + // MARK: - Content Views private var subscriptionsQueueSource: QueueSource {