mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
A synced context change had no effect on the pre-revamp `subscriptionAccount` setting several views still read, nor on the feed cache, which is global rather than account-keyed — so a selection made on another device left this one showing the previous account's subscriptions and videos. Both now follow every context change, whoever made it. Instances replaced wholesale by iCloud bypassed `saveInstances()` and so posted no change notification. A legacy authenticated instance arriving that way never reached account migration: no account row, and no action to adopt its saved login, until an unrelated edit or a relaunch. Deleting an instance now deletes the accounts on it, while the instance is still resolvable — afterwards the account's credential manager cannot be determined from its type and the credential would be stranded. Only an explicit deletion cascades; an instance vanishing from iCloud leaves its accounts orphaned, because absence is not a deletion. A context whose instance has been switched off is temporarily unavailable rather than valid. The account stays signed in, so nothing about the account said the server had been turned off, and requests kept going to it while the switcher already hid it.
661 lines
22 KiB
Swift
661 lines
22 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
|
|
}
|
|
}
|
|
#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 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)
|
|
}
|