mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Foreground activation repairs a selection whose account this device knows was deleted. The check for whether that is safe was the identity scope being resolved, which it already is in the window between an Apple account change moving the namespace and the reload refilling memory — so the repair weighed the previous identity's selection against the previous identity's tombstones and announced the result to a user who had not been shown an account yet. Fenced on the loaded namespace, the same way saves are. Three smaller things around the same theme: - The destination rows forgot their membership answers on a credential or account change but not on an instance change, which adds a row to the picker while it is open. Unknown reads as unsubscribed, so that row offered to subscribe a channel the account already has. - The subscriptions settings count applied whatever the fetch returned. A selection change is fenced by the service's provider generation, but the fallback to local is driven by availability and announces nothing, so the context is captured before the fetch and rechecked after it. - Deleting a source from the browser runs the same cascade as deleting it from Settings, and was the one dialog left that did not say the logins go with it.
673 lines
23 KiB
Swift
673 lines
23 KiB
Swift
//
|
|
// MediaSourcesView.swift
|
|
// Yattee
|
|
//
|
|
// View for browsing all configured sources (instances and media sources).
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MediaSourcesView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var sourceToEdit: UnifiedSource?
|
|
@State private var showingAddSheet = false
|
|
@State private var showingDeleteConfirmation = false
|
|
@State private var pendingDeleteInstance: Instance?
|
|
@State private var pendingDeleteSource: MediaSource?
|
|
|
|
#if os(tvOS)
|
|
@FocusState private var firstSourceFocused: Bool
|
|
#endif
|
|
|
|
private var instancesManager: InstancesManager? {
|
|
appEnvironment?.instancesManager
|
|
}
|
|
|
|
private var mediaSourcesManager: MediaSourcesManager? {
|
|
appEnvironment?.mediaSourcesManager
|
|
}
|
|
|
|
private var sourcesSettings: SourcesSettings? {
|
|
appEnvironment?.sourcesSettings
|
|
}
|
|
|
|
private var isEmpty: Bool {
|
|
(instancesManager?.enabledInstances.isEmpty ?? true) &&
|
|
(mediaSourcesManager?.enabledSources.isEmpty ?? true)
|
|
}
|
|
|
|
private var listStyle: VideoListStyle {
|
|
appEnvironment?.settingsManager.listStyle ?? .inset
|
|
}
|
|
|
|
/// Whether a remote server instance requires credentials that are missing from
|
|
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
|
|
private func needsCredentials(_ instance: Instance) -> Bool {
|
|
appEnvironment?.needsCredentials(for: instance) ?? false
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
#if os(tvOS)
|
|
TVSidebarDetailContainer(
|
|
systemImage: "server.rack",
|
|
title: String(localized: "sources.title")
|
|
) {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 24) {
|
|
Button {
|
|
showingAddSheet = true
|
|
} label: {
|
|
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
|
}
|
|
if let settings = sourcesSettings {
|
|
sortAndGroupMenu(settings)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
.focusSection()
|
|
|
|
mediaSourcesInner
|
|
.focusSection()
|
|
}
|
|
}
|
|
#else
|
|
mediaSourcesInner
|
|
#endif
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "sources.title"))
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showingAddSheet = true
|
|
} label: {
|
|
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
if let settings = sourcesSettings {
|
|
sortAndGroupMenu(settings)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#if os(tvOS)
|
|
.navigationDestination(item: $sourceToEdit) { source in
|
|
TVSidebarDetailContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) {
|
|
EditSourceView(source: source)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $showingAddSheet) {
|
|
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) {
|
|
AddSourceView()
|
|
}
|
|
}
|
|
#else
|
|
.sheet(item: $sourceToEdit) { source in
|
|
EditSourceView(source: source)
|
|
}
|
|
.sheet(isPresented: $showingAddSheet) {
|
|
AddSourceView()
|
|
}
|
|
#endif
|
|
.confirmationDialog(
|
|
deleteConfirmationMessage,
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
confirmDelete()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
pendingDeleteInstance = nil
|
|
pendingDeleteSource = nil
|
|
}
|
|
} message: {
|
|
// The same cascade runs from here as from Settings, so the same
|
|
// logins go with it — this dialog was simply the one still silent
|
|
// about it.
|
|
if let accounts = deleteConfirmationAccounts {
|
|
Text(accounts)
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.presentationCompactAdaptation(.sheet)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var mediaSourcesInner: some View {
|
|
if isEmpty {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "sources.empty.title"), systemImage: "server.rack")
|
|
} description: {
|
|
Text(String(localized: "sources.empty.description"))
|
|
} actions: {
|
|
Button(String(localized: "sources.addSource")) {
|
|
showingAddSheet = true
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
sourcesList
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private var deleteConfirmationMessage: String {
|
|
if let instance = pendingDeleteInstance {
|
|
return String(localized: "sources.delete.confirmation.single \(instance.displayName)")
|
|
} else if let source = pendingDeleteSource {
|
|
return String(localized: "sources.delete.confirmation.single \(source.name)")
|
|
}
|
|
return String(localized: "sources.delete.confirmation")
|
|
}
|
|
|
|
private var deleteConfirmationAccounts: String? {
|
|
guard let instance = pendingDeleteInstance else { return nil }
|
|
return InstanceDeletionDisclosure.message(for: instance, in: appEnvironment)
|
|
}
|
|
|
|
private func confirmDelete() {
|
|
if let instance = pendingDeleteInstance {
|
|
appEnvironment?.removeInstance(instance)
|
|
pendingDeleteInstance = nil
|
|
}
|
|
if let source = pendingDeleteSource {
|
|
mediaSourcesManager?.remove(source)
|
|
pendingDeleteSource = nil
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sectionHeader(_ title: String) -> some View {
|
|
#if os(macOS)
|
|
Text(title)
|
|
.font(.subheadline)
|
|
.textCase(.uppercase)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 4)
|
|
#else
|
|
Text(title)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, listStyle == .inset ? 32 : 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
#endif
|
|
}
|
|
|
|
private var sourcesList: some View {
|
|
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
|
|
.ignoresSafeArea()
|
|
.overlay(
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
if let settings = sourcesSettings, !settings.groupByType {
|
|
// Ungrouped: All sources in one section
|
|
allSourcesSection(settings)
|
|
} else {
|
|
// Grouped by type (default)
|
|
groupedSourcesSections
|
|
}
|
|
}
|
|
}
|
|
)
|
|
#if os(tvOS)
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
firstSourceFocused = true
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var groupedSourcesSections: some View {
|
|
let settings = sourcesSettings
|
|
let hasInstances = !(instancesManager?.enabledInstances.isEmpty ?? true)
|
|
|
|
// Instances section
|
|
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
|
|
sectionHeader(String(localized: "sources.section.remoteServers"))
|
|
|
|
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
|
|
sectionCard {
|
|
instancesSectionContent(sortedInstances, firstIsGlobalFirst: true)
|
|
}
|
|
}
|
|
|
|
// Media sources section
|
|
if let manager = mediaSourcesManager, !manager.enabledSources.isEmpty {
|
|
sectionHeader(String(localized: "sources.section.fileSources"))
|
|
|
|
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
|
|
sectionCard {
|
|
fileSourcesSectionContent(sortedSources, firstIsGlobalFirst: !hasInstances)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func allSourcesSection(_ settings: SourcesSettings) -> some View {
|
|
let sortedSources = allUnifiedSources(settings: settings)
|
|
|
|
if !sortedSources.isEmpty {
|
|
sectionHeader(String(localized: "sources.section.allSources"))
|
|
|
|
sectionCard {
|
|
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
|
|
let isLast = index == sortedSources.count - 1
|
|
let isFirst = index == 0
|
|
|
|
switch item {
|
|
case .instance(let instance):
|
|
instanceRowView(instance, isLast: isLast, isFirst: isFirst)
|
|
case .mediaSource(let source):
|
|
fileSourceRowView(source, isLast: isLast, isFirst: isFirst)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func allUnifiedSources(settings: SourcesSettings) -> [UnifiedSourceItem] {
|
|
let instances = instancesManager?.enabledInstances ?? []
|
|
let mediaSources = mediaSourcesManager?.enabledSources ?? []
|
|
|
|
var allSources: [UnifiedSourceItem] = []
|
|
allSources.append(contentsOf: instances.map { UnifiedSourceItem.instance($0) })
|
|
allSources.append(contentsOf: mediaSources.map { UnifiedSourceItem.mediaSource($0) })
|
|
|
|
return sortUnifiedSources(allSources, settings: settings)
|
|
}
|
|
|
|
private func sortUnifiedSources(_ sources: [UnifiedSourceItem], settings: SourcesSettings) -> [UnifiedSourceItem] {
|
|
sources.sorted { first, second in
|
|
let comparison: Bool
|
|
switch settings.sortOption {
|
|
case .name:
|
|
comparison = first.displayName.localizedCaseInsensitiveCompare(second.displayName) == .orderedAscending
|
|
case .type:
|
|
comparison = first.typeDisplayName.localizedCaseInsensitiveCompare(second.typeDisplayName) == .orderedAscending
|
|
case .dateAdded:
|
|
comparison = first.dateAdded < second.dateAdded
|
|
}
|
|
return settings.sortDirection == .ascending ? comparison : !comparison
|
|
}
|
|
}
|
|
|
|
// MARK: - Sort and Group Menu
|
|
|
|
@ViewBuilder
|
|
private func sortAndGroupMenu(_ settings: SourcesSettings) -> some View {
|
|
Menu {
|
|
// Sort options
|
|
Section {
|
|
Picker(selection: Binding(
|
|
get: { settings.sortOption },
|
|
set: { settings.sortOption = $0 }
|
|
)) {
|
|
ForEach(settings.availableSortOptions, id: \.self) { option in
|
|
Label(option.displayName, systemImage: option.systemImage)
|
|
.tag(option)
|
|
}
|
|
} label: {
|
|
Label(String(localized: "sources.sort.title"), systemImage: "arrow.up.arrow.down")
|
|
}
|
|
|
|
// Sort direction
|
|
Button {
|
|
settings.sortDirection.toggle()
|
|
} label: {
|
|
Label(
|
|
settings.sortDirection == .ascending
|
|
? String(localized: "sources.sort.ascending")
|
|
: String(localized: "sources.sort.descending"),
|
|
systemImage: settings.sortDirection.systemImage
|
|
)
|
|
}
|
|
}
|
|
|
|
// Grouping
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { settings.groupByType },
|
|
set: {
|
|
settings.groupByType = $0
|
|
// Reset to name sort if type sort was selected and grouping is now enabled
|
|
if $0 && settings.sortOption == .type {
|
|
settings.sortOption = .name
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "sources.groupByType"), systemImage: "rectangle.3.group")
|
|
}
|
|
}
|
|
} label: {
|
|
Label(String(localized: "sources.sortAndGroup"), systemImage: "line.3.horizontal.decrease.circle")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
#if os(macOS)
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
LazyVStack(spacing: 0) {
|
|
content()
|
|
}
|
|
Divider()
|
|
}
|
|
.padding(.bottom, 12)
|
|
#else
|
|
if listStyle == .inset {
|
|
LazyVStack(spacing: 0) {
|
|
content()
|
|
}
|
|
.background(ListBackgroundStyle.card.color)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
} else {
|
|
LazyVStack(spacing: 0) {
|
|
content()
|
|
}
|
|
.padding(.bottom, 16)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func instancesSectionContent(_ instances: [Instance], firstIsGlobalFirst: Bool = false) -> some View {
|
|
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
|
|
let isLastInSection = index == instances.count - 1
|
|
|
|
instanceRowView(instance, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func instanceRowView(_ instance: Instance, isLast: Bool, isFirst: Bool = false) -> some View {
|
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
|
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
|
|
instanceRow(instance)
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.plain)
|
|
#else
|
|
.foregroundStyle(.primary)
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
|
#endif
|
|
.swipeActions {
|
|
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
|
sourceToEdit = .remoteServer(instance)
|
|
reset()
|
|
}
|
|
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
|
|
pendingDeleteInstance = instance
|
|
showingDeleteConfirmation = true
|
|
reset()
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
sourceToEdit = .remoteServer(instance)
|
|
} label: {
|
|
Label(String(localized: "common.edit"), systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
pendingDeleteInstance = instance
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "common.delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func fileSourcesSectionContent(_ sources: [MediaSource], firstIsGlobalFirst: Bool = false) -> some View {
|
|
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
|
|
let isLastInSection = index == sources.count - 1
|
|
|
|
fileSourceRowView(source, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func fileSourceRowView(_ source: MediaSource, isLast: Bool, isFirst: Bool = false) -> some View {
|
|
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
|
|
|
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
|
if needsPassword {
|
|
Button {
|
|
sourceToEdit = .fileSource(source)
|
|
} label: {
|
|
mediaSourceRow(source, needsPassword: true)
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.plain)
|
|
#else
|
|
.foregroundStyle(.primary)
|
|
#endif
|
|
} else {
|
|
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) {
|
|
mediaSourceRow(source, needsPassword: false)
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.plain)
|
|
#else
|
|
.foregroundStyle(.primary)
|
|
#endif
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
|
#endif
|
|
.swipeActions {
|
|
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
|
sourceToEdit = .fileSource(source)
|
|
reset()
|
|
}
|
|
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
|
|
pendingDeleteSource = source
|
|
showingDeleteConfirmation = true
|
|
reset()
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
sourceToEdit = .fileSource(source)
|
|
} label: {
|
|
Label(String(localized: "common.edit"), systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
pendingDeleteSource = source
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "common.delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func instanceRow(_ instance: Instance) -> 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: instance.type.systemImage)
|
|
.font(iconFont)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: iconFrameWidth)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(instance.displayName)
|
|
.font(titleFont)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if needsCredentials(instance) {
|
|
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
#if !os(macOS)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
#endif
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> 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: source.type.systemImage)
|
|
.font(iconFont)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: iconFrameWidth)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(source.name)
|
|
.font(titleFont)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text("\(source.type.displayName) - \(source.urlDisplayString)")
|
|
.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(.secondary)
|
|
#endif
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Unified Source Item
|
|
|
|
/// Unified wrapper for sorting instances and media sources together.
|
|
private enum UnifiedSourceItem: Identifiable {
|
|
case instance(Instance)
|
|
case mediaSource(MediaSource)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .instance(let instance):
|
|
return "instance-\(instance.id.uuidString)"
|
|
case .mediaSource(let source):
|
|
return "source-\(source.id.uuidString)"
|
|
}
|
|
}
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .instance(let instance):
|
|
return instance.displayName
|
|
case .mediaSource(let source):
|
|
return source.name
|
|
}
|
|
}
|
|
|
|
var typeDisplayName: String {
|
|
switch self {
|
|
case .instance(let instance):
|
|
return instance.type.displayName
|
|
case .mediaSource(let source):
|
|
return source.type.displayName
|
|
}
|
|
}
|
|
|
|
var dateAdded: Date {
|
|
switch self {
|
|
case .instance(let instance):
|
|
return instance.dateAdded
|
|
case .mediaSource(let source):
|
|
return source.dateAdded
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private struct FirstRowFocusModifier: ViewModifier {
|
|
let isFirst: Bool
|
|
var focus: FocusState<Bool>.Binding
|
|
|
|
func body(content: Content) -> some View {
|
|
if isFirst {
|
|
content.focused(focus)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
MediaSourcesView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|