diff --git a/Yattee/Views/Home/HomeItem.swift b/Yattee/Views/Home/HomeItem.swift index 5b44cc46..cd42f5e3 100644 --- a/Yattee/Views/Home/HomeItem.swift +++ b/Yattee/Views/Home/HomeItem.swift @@ -434,6 +434,12 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { case bookmarks case history case downloads + /// All enabled remote servers, listed like the Sources screen; tapping one + /// opens its browse view. + case remoteServers + /// All enabled file sources (WebDAV, SMB, local folders), listed like the + /// Sources screen; tapping one opens its file browser. + case fileSources case instanceContent(instanceID: UUID, contentType: InstanceContentType) /// Legacy, decode-only. Media sources are shortcuts-only now — a media-source /// "section" was just a browse link. Kept so existing saved `homeSectionOrder` @@ -449,6 +455,8 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { case .bookmarks: return "bookmarks" case .history: return "history" case .downloads: return "downloads" + case .remoteServers: return "remoteServers" + case .fileSources: return "fileSources" case .instanceContent(let instanceID, let contentType): return "instance_\(instanceID.uuidString)_\(contentType.rawValue)" case .mediaSource(let sourceID): @@ -459,18 +467,18 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { /// Default order for sections. static var defaultOrder: [HomeSectionItem] { #if os(tvOS) - [.continueWatching, .feed, .bookmarks, .history] + [.continueWatching, .feed, .bookmarks, .history, .remoteServers, .fileSources] #else - [.continueWatching, .feed, .bookmarks, .history, .downloads] + [.continueWatching, .feed, .bookmarks, .history, .downloads, .remoteServers, .fileSources] #endif } /// Default visibility for sections (only continue watching on by default). static var defaultVisibility: [HomeSectionItem: Bool] { #if os(tvOS) - [.continueWatching: true, .feed: false, .bookmarks: false, .history: false] + [.continueWatching: true, .feed: false, .bookmarks: false, .history: false, .remoteServers: false, .fileSources: false] #else - [.continueWatching: true, .feed: false, .bookmarks: false, .history: false, .downloads: false] + [.continueWatching: true, .feed: false, .bookmarks: false, .history: false, .downloads: false, .remoteServers: false, .fileSources: false] #endif } @@ -483,6 +491,8 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { case .bookmarks: return "bookmark.fill" case .history: return "clock.arrow.circlepath" case .downloads: return "arrow.down.circle.fill" + case .remoteServers: return "server.rack" + case .fileSources: return "externaldrive.connected.to.line.below" case .instanceContent(_, let contentType): return contentType.icon case .mediaSource: @@ -499,6 +509,8 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { case .bookmarks: return String(localized: "home.section.bookmarks") case .history: return String(localized: "home.section.history") case .downloads: return String(localized: "home.section.downloads") + case .remoteServers: return String(localized: "sources.section.remoteServers") + case .fileSources: return String(localized: "sources.section.fileSources") case .instanceContent(_, let contentType): return contentType.localizedTitle case .mediaSource: @@ -521,6 +533,8 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { case bookmarks case history case downloads + case remoteServers + case fileSources case instanceContent case mediaSource } @@ -539,6 +553,10 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { try container.encode(SectionType.history, forKey: .type) case .downloads: try container.encode(SectionType.downloads, forKey: .type) + case .remoteServers: + try container.encode(SectionType.remoteServers, forKey: .type) + case .fileSources: + try container.encode(SectionType.fileSources, forKey: .type) case .instanceContent(let instanceID, let contentType): try container.encode(SectionType.instanceContent, forKey: .type) try container.encode(instanceID, forKey: .instanceID) @@ -564,6 +582,10 @@ enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable { self = .history case .downloads: self = .downloads + case .remoteServers: + self = .remoteServers + case .fileSources: + self = .fileSources case .instanceContent: let instanceID = try container.decode(UUID.self, forKey: .instanceID) let contentType = try container.decode(InstanceContentType.self, forKey: .contentType) diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 92a96e54..bd883977 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -335,6 +335,10 @@ struct HomeView: View { } case .downloads: break + case .remoteServers: + if !homeRemoteServers.isEmpty { return false } + case .fileSources: + if !homeFileSources.isEmpty { return false } case .instanceContent(let instanceID, let contentType): if hasInstanceContent(instanceID: instanceID, contentType: contentType) { return false } case .mediaSource: @@ -1279,6 +1283,14 @@ struct HomeView: View { #else EmptyView() #endif + case .remoteServers: + if !homeRemoteServers.isEmpty { + remoteServersSection + } + case .fileSources: + if !homeFileSources.isEmpty { + fileSourcesSection + } case .instanceContent(let instanceID, let contentType): instanceContentSection(instanceID: instanceID, contentType: contentType) case .mediaSource: @@ -1289,6 +1301,141 @@ struct HomeView: View { } } + // MARK: - Sources Sections + + /// Enabled remote servers in the Sources screen's order. + private var homeRemoteServers: [Instance] { + let instances = appEnvironment?.instancesManager.enabledInstances ?? [] + return appEnvironment?.sourcesSettings.sorted(instances) ?? instances + } + + /// Enabled file sources in the Sources screen's order. + private var homeFileSources: [MediaSource] { + let sources = appEnvironment?.mediaSourcesManager.enabledSources ?? [] + return appEnvironment?.sourcesSettings.sorted(sources) ?? sources + } + + private var remoteServersSection: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(title: "sources.section.remoteServers") { + appEnvironment?.navigationCoordinator.navigate(to: .mediaSources) + } + + VideoListContent(listStyle: listStyle) { + let instances = homeRemoteServers + ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in + SourceListRow(isLast: index == instances.count - 1, listStyle: listStyle) { + Button { + appEnvironment?.navigationCoordinator.navigate(to: .instanceBrowse(instance)) + } label: { + homeSourceRow( + systemImage: instance.type.systemImage, + name: instance.displayName, + detail: "\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)" + ) + } + #if os(macOS) + .buttonStyle(.plain) + #elseif os(tvOS) + .buttonStyle(TVHomeRowButtonStyle()) + #else + .foregroundStyle(.primary) + #endif + } + } + } + } + } + + private var fileSourcesSection: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(title: "sources.section.fileSources") { + appEnvironment?.navigationCoordinator.navigate(to: .mediaSources) + } + + VideoListContent(listStyle: listStyle) { + let sources = homeFileSources + ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in + let needsPassword = appEnvironment?.mediaSourcesManager.needsPassword(for: source) ?? false + SourceListRow(isLast: index == sources.count - 1, listStyle: listStyle) { + Button { + if needsPassword { + // The browser can't open without credentials; the + // Sources screen holds the edit flow that fixes it. + appEnvironment?.navigationCoordinator.navigate(to: .mediaSources) + } else { + appEnvironment?.navigationCoordinator.navigate(to: .mediaBrowser(source, path: "/")) + } + } label: { + homeSourceRow( + systemImage: source.type.systemImage, + name: source.name, + detail: "\(source.type.displayName) - \(source.urlDisplayString)", + needsPassword: needsPassword + ) + } + #if os(macOS) + .buttonStyle(.plain) + #elseif os(tvOS) + .buttonStyle(TVHomeRowButtonStyle()) + #else + .foregroundStyle(.primary) + #endif + } + } + } + } + } + + /// Row content matching the Sources screen's listing. + private func homeSourceRow(systemImage: String, name: String, detail: String, needsPassword: Bool = false) -> some View { + #if os(tvOS) + let rowSpacing: CGFloat = 24 + #else + let rowSpacing: CGFloat = 12 + #endif + #if os(macOS) + let iconFont: Font = .title3 + let iconFrameWidth: CGFloat = 24 + let titleFont: Font = .body + #else + let iconFont: Font = .title2 + let iconFrameWidth: CGFloat = 32 + let titleFont: Font = .headline + #endif + return HStack(spacing: rowSpacing) { + Image(systemName: systemImage) + .font(iconFont) + .foregroundStyle(.tint) + .frame(width: iconFrameWidth) + + VStack(alignment: .leading, spacing: 2) { + Text(name) + .font(titleFont) + .foregroundStyle(.primary) + + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + + if needsPassword { + Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + + Spacer() + + #if !os(macOS) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + #endif + } + .contentShape(Rectangle()) + } + private var continueWatchingSection: some View { let limitedEntries = Array(recentContinueWatching.prefix(sectionItemsLimit)) let videoList = limitedEntries.map { $0.toVideo() }