Add Remote Servers and File Sources sections to Home

Two new configurable Home sections list enabled sources the way the
Sources screen does; tapping a remote server opens its browse view and
tapping a file source opens its file browser. A file source with missing
credentials shows the auth-required badge and routes to the Sources
screen instead. Both sections are hidden by default.
This commit is contained in:
Arkadiusz Fal
2026-09-20 23:45:19 +02:00
parent c03df4777e
commit 1ac35f2273
2 changed files with 173 additions and 4 deletions

View File

@@ -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)

View File

@@ -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() }