Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,280 @@
//
// MediaBrowserView.swift
// Yattee
//
// View for browsing files in a media source.
//
import SwiftUI
struct MediaBrowserView: View {
let source: MediaSource
let initialPath: String
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
@State private var currentPath: String
@State private var files: [MediaFile] = []
@State private var isLoading = false
@State private var error: MediaSourceError?
@State private var showOnlyPlayable: Bool
@State private var sortOrder: MediaBrowserSortOrder = .name
@State private var sortAscending = true
@State private var showViewOptions = false
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
init(source: MediaSource, path: String = "/", showOnlyPlayable: Bool = false) {
self.source = source
self.initialPath = path
_currentPath = State(initialValue: path)
_showOnlyPlayable = State(initialValue: showOnlyPlayable)
}
/// Files filtered and sorted based on current settings.
private var displayedFiles: [MediaFile] {
var result = files
if showOnlyPlayable {
result = result.filter { $0.isDirectory || $0.isPlayable }
}
return sortedFiles(result)
}
var body: some View {
Group {
if isLoading && files.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button(String(localized: "common.retry")) {
Task { await loadFiles() }
}
.buttonStyle(.borderedProminent)
}
} else if files.isEmpty {
ContentUnavailableView {
Label(String(localized: "mediaBrowser.emptyFolder"), systemImage: "folder")
} description: {
Text(String(localized: "mediaBrowser.emptyFolder.description"))
}
} else {
fileList
}
}
.navigationTitle(navigationTitle)
#if !os(tvOS)
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar {
#if !os(tvOS)
ToolbarItem(placement: .primaryAction) {
if isLoading {
ProgressView()
.controlSize(.small)
} else {
Button {
Task { await loadFiles() }
} label: {
Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise")
}
}
}
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(
"View Options",
systemImage: showOnlyPlayable
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle"
)
}
.liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition)
}
#endif
}
.sheet(isPresented: $showViewOptions) {
MediaBrowserViewOptionsSheet(
sortOrder: $sortOrder,
sortAscending: $sortAscending,
showOnlyPlayable: $showOnlyPlayable,
sourceType: source.type
)
.liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition)
}
.task {
await loadFiles()
}
}
private var navigationTitle: String {
if currentPath == "/" || currentPath.isEmpty {
return source.name
}
return (currentPath as NSString).lastPathComponent
}
private var fileList: some View {
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
.ignoresSafeArea()
.overlay(
ScrollView {
LazyVStack(spacing: 0) {
sectionCard {
ForEach(Array(displayedFiles.enumerated()), id: \.element.id) { index, file in
let isLast = index == displayedFiles.count - 1
SourceListRow(isLast: isLast, listStyle: listStyle) {
if file.isDirectory {
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: file.path, showOnlyPlayable: showOnlyPlayable)) {
MediaFileRow(file: file, sortOrder: sortOrder)
}
.foregroundStyle(.primary)
} else {
MediaFileRow(file: file, sortOrder: sortOrder) {
if file.isPlayable {
playFile(file)
}
}
}
}
}
}
}
.padding(.top, 16)
}
)
}
@ViewBuilder
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
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)
}
}
// MARK: - Loading
@MainActor
private func loadFiles() async {
guard let appEnvironment else { return }
isLoading = true
error = nil
do {
let loadedFiles: [MediaFile]
switch source.type {
case .webdav:
let password = appEnvironment.mediaSourcesManager.password(for: source)
loadedFiles = try await appEnvironment.webDAVClient.listFiles(
at: currentPath,
source: source,
password: password
)
case .smb:
let password = appEnvironment.mediaSourcesManager.password(for: source)
loadedFiles = try await appEnvironment.smbClient.listFiles(
at: currentPath,
source: source,
password: password
)
case .localFolder:
loadedFiles = try await appEnvironment.localFileClient.listFiles(
at: currentPath,
source: source
)
}
files = loadedFiles
isLoading = false
} catch let err as MediaSourceError {
error = err
isLoading = false
} catch {
self.error = .unknown(error.localizedDescription)
isLoading = false
}
}
private func sortedFiles(_ files: [MediaFile]) -> [MediaFile] {
files.sorted { lhs, rhs in
// Directories always first
if lhs.isDirectory != rhs.isDirectory {
return lhs.isDirectory
}
let comparison: ComparisonResult
switch sortOrder {
case .name:
comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
case .dateModified:
let lhsDate = lhs.modifiedDate ?? .distantPast
let rhsDate = rhs.modifiedDate ?? .distantPast
comparison = lhsDate.compare(rhsDate)
case .dateCreated:
let lhsDate = lhs.createdDate ?? .distantPast
let rhsDate = rhs.createdDate ?? .distantPast
comparison = lhsDate.compare(rhsDate)
}
return sortAscending ? comparison == .orderedAscending : comparison == .orderedDescending
}
}
// MARK: - Playback
private func playFile(_ file: MediaFile) {
guard let appEnvironment else { return }
// Get all playable files in current sort order
let playableFiles = displayedFiles.filter { $0.isPlayable }
// Find the index of the tapped file in the playable files list
guard let playableIndex = playableFiles.firstIndex(where: { $0.id == file.id }) else {
return
}
// Use queue-based playback with all files in the folder
// Stream and captions are resolved on-demand when each video plays
appEnvironment.queueManager.playFromMediaBrowser(
files: playableFiles,
index: playableIndex,
source: source,
allFilesInFolder: files // All files including subtitles for discovery
)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
MediaBrowserView(
source: .webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!)
)
}
.appEnvironment(.preview)
}

View File

@@ -0,0 +1,110 @@
//
// MediaBrowserViewOptionsSheet.swift
// Yattee
//
// Sheet for customizing media browser view options.
//
import SwiftUI
struct MediaBrowserViewOptionsSheet: View {
@Binding var sortOrder: MediaBrowserSortOrder
@Binding var sortAscending: Bool
@Binding var showOnlyPlayable: Bool
let sourceType: MediaSourceType
@Environment(\.dismiss) private var dismiss
private var availableSortOptions: [MediaBrowserSortOrder] {
MediaBrowserSortOrder.availableOptions(for: sourceType)
}
var body: some View {
NavigationStack {
Form {
Section {
Picker("mediaBrowser.viewOptions.sortBy", selection: $sortOrder) {
ForEach(availableSortOptions) { order in
Label(order.displayName, systemImage: order.systemImage)
.tag(order)
}
}
} header: {
Text("mediaBrowser.viewOptions.sortBy")
}
Section {
Picker("mediaBrowser.viewOptions.order", selection: $sortAscending) {
Label(String(localized: "mediaBrowser.viewOptions.ascending"), systemImage: "arrow.up")
.tag(true)
Label(String(localized: "mediaBrowser.viewOptions.descending"), systemImage: "arrow.down")
.tag(false)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
} header: {
Text("mediaBrowser.viewOptions.order")
}
Section {
Toggle("mediaBrowser.viewOptions.showOnlyPlayable", isOn: $showOnlyPlayable)
} header: {
Text("mediaBrowser.viewOptions.filters")
}
}
.navigationTitle("mediaBrowser.viewOptions.title")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
}
#if os(iOS)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
#endif
.onAppear {
// Reset sort order if current selection is not available for this source type
if !availableSortOptions.contains(sortOrder) {
sortOrder = .name
}
}
}
}
// MARK: - Preview
#Preview("Local Folder") {
@Previewable @State var sortOrder: MediaBrowserSortOrder = .name
@Previewable @State var sortAscending = true
@Previewable @State var showOnlyPlayable = false
MediaBrowserViewOptionsSheet(
sortOrder: $sortOrder,
sortAscending: $sortAscending,
showOnlyPlayable: $showOnlyPlayable,
sourceType: .localFolder
)
}
#Preview("WebDAV") {
@Previewable @State var sortOrder: MediaBrowserSortOrder = .name
@Previewable @State var sortAscending = true
@Previewable @State var showOnlyPlayable = false
MediaBrowserViewOptionsSheet(
sortOrder: $sortOrder,
sortAscending: $sortAscending,
showOnlyPlayable: $showOnlyPlayable,
sourceType: .webdav
)
}

View File

@@ -0,0 +1,128 @@
//
// MediaFileRow.swift
// Yattee
//
// Row view for displaying a file or folder in the media browser.
//
import SwiftUI
struct MediaFileRow: View {
let file: MediaFile
let sortOrder: MediaBrowserSortOrder
let action: (() -> Void)?
/// Initialize with an action (for playable files).
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name, action: @escaping () -> Void) {
self.file = file
self.sortOrder = sortOrder
self.action = action
}
/// Initialize without action (for use inside NavigationLink).
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name) {
self.file = file
self.sortOrder = sortOrder
self.action = nil
}
/// The date to display based on current sort order.
private var displayDate: Date? {
switch sortOrder {
case .name, .dateModified:
file.modifiedDate
case .dateCreated:
file.createdDate
}
}
var body: some View {
if let action {
Button(action: action) {
rowContent
}
.buttonStyle(.plain)
.if(file.isPlayable) { view in
view.videoContextMenu(
video: file.toVideo(),
context: .mediaBrowser
)
}
} else {
rowContent
.if(file.isPlayable) { view in
view.videoContextMenu(
video: file.toVideo(),
context: .mediaBrowser
)
}
}
}
private var rowContent: some View {
HStack(spacing: 12) {
// Icon
Image(systemName: file.systemImage)
.font(.title2)
.foregroundStyle(iconColor)
.frame(width: 32)
// File info
VStack(alignment: .leading, spacing: 2) {
Text(file.name)
.font(.body)
.lineLimit(2)
.foregroundStyle(.primary)
HStack(spacing: 8) {
if let size = file.formattedSize {
Text(size)
.font(.caption)
.foregroundStyle(.secondary)
}
if let date = displayDate {
Text(date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Spacer()
}
.contentShape(Rectangle())
}
private var iconColor: Color {
if file.isDirectory {
return .blue
}
if file.isVideo {
return .purple
}
if file.isAudio {
return .pink
}
return .secondary
}
}
// MARK: - Preview
#Preview {
List {
MediaFileRow(file: .folderPreview) {}
MediaFileRow(file: .preview) {}
MediaFileRow(
file: MediaFile(
source: .webdav(name: "NAS", url: URL(string: "https://nas.local")!),
path: "/Music/song.mp3",
name: "song.mp3",
isDirectory: false,
size: 5_000_000,
modifiedDate: Date()
)
) {}
}
}

View File

@@ -0,0 +1,496 @@
//
// 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?
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
}
var body: some View {
Group {
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)
}
} else {
sourcesList
}
}
.navigationTitle(String(localized: "sources.title"))
#if !os(tvOS)
.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
.sheet(item: $sourceToEdit) { source in
EditSourceView(source: source)
}
.sheet(isPresented: $showingAddSheet) {
AddSourceView()
}
.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
}
}
}
// 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 {
instancesManager?.remove(instance)
pendingDeleteInstance = nil
}
if let source = pendingDeleteSource {
mediaSourcesManager?.remove(source)
pendingDeleteSource = nil
}
}
@ViewBuilder
private func sectionHeader(_ title: String) -> some View {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16)
.padding(.bottom, 8)
}
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
}
}
}
)
}
@ViewBuilder
private var groupedSourcesSections: some View {
let settings = sourcesSettings
// 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)
}
}
// 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)
}
}
}
@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
switch item {
case .instance(let instance):
instanceRowView(instance, isLast: isLast)
case .mediaSource(let source):
fileSourceRowView(source, isLast: isLast)
}
}
}
}
}
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 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)
}
}
@ViewBuilder
private func instancesSectionContent(_ instances: [Instance]) -> some View {
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
let isLastInSection = index == instances.count - 1
instanceRowView(instance, isLast: isLastInSection)
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool) -> some View {
SourceListRow(isLast: isLast, listStyle: listStyle) {
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
instanceRow(instance)
}
.foregroundStyle(.primary)
}
.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]) -> some View {
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
let isLastInSection = index == sources.count - 1
fileSourceRowView(source, isLast: isLastInSection)
}
}
@ViewBuilder
private func fileSourceRowView(_ source: MediaSource, isLast: Bool) -> 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)
}
.foregroundStyle(.primary)
} else {
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) {
mediaSourceRow(source, needsPassword: false)
}
.foregroundStyle(.primary)
}
}
.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 {
HStack(spacing: 12) {
Image(systemName: instance.type.systemImage)
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(instance.displayName)
.font(.headline)
.foregroundStyle(.primary)
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
HStack(spacing: 12) {
Image(systemName: source.type.systemImage)
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.font(.headline)
.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()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// 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
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
MediaSourcesView()
}
.appEnvironment(.preview)
}