feat: 重构 Emby 风格影视库导航与海报墙

This commit is contained in:
2026-07-16 14:59:30 +08:00
parent ec7f0024e7
commit d56af3c4e1
2 changed files with 235 additions and 96 deletions
+56 -3
View File
@@ -316,6 +316,7 @@ struct WorkspaceSidebar: View {
@Binding var columnVisibility: NavigationSplitViewVisibility
@State private var toolsExpanded = true
@State private var categoriesExpanded = false
@State private var mediaExpanded = true
var body: some View {
VStack(alignment: .leading, spacing: 18) {
@@ -333,9 +334,37 @@ struct WorkspaceSidebar: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 18) {
ToolSidebarItem(tool: .media, selected: selectedTool == .media) {
model.mediaLibraryTarget = nil
selectedTool = .media
VStack(spacing: 5) {
Button {
withAnimation(.easeInOut(duration: 0.18)) { mediaExpanded = true }
model.mediaLibraryTarget = nil
model.mediaLibraryDestination = .all
selectedTool = .media
} label: {
HStack(spacing: 10) {
Image(systemName: "play.tv.fill").foregroundStyle(AppTheme.orange).frame(width: 26)
Text("影视库").font(.callout.weight(.semibold))
Spacer()
}
.padding(10).background(selectedTool == .media && model.mediaLibraryDestination == .all ? AppTheme.orange.opacity(0.14) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
if mediaExpanded {
VStack(spacing: 3) {
MediaLibrarySidebarRow(title: "所有影视", icon: "rectangle.grid.2x2", isSelected: selectedTool == .media && model.mediaLibraryDestination == .all) {
model.mediaLibraryDestination = .all; selectedTool = .media
}
ForEach(model.mediaLibraries) { library in
MediaLibrarySidebarRow(title: library.name, icon: library.kind.icon, isSelected: selectedTool == .media && model.mediaLibraryDestination == .library(library.id)) {
model.mediaLibraryDestination = .library(library.id); selectedTool = .media
}
}
MediaLibrarySidebarRow(title: "刮削任务", icon: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "arrow.triangle.2.circlepath" : "checklist", isSelected: selectedTool == .media && model.mediaLibraryDestination == .tasks, status: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "进行中" : nil) {
model.mediaLibraryDestination = .tasks; selectedTool = .media
}
}
.padding(.leading, 18)
}
}
VStack(alignment: .leading, spacing: 8) {
Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8)
@@ -419,6 +448,30 @@ struct WorkspaceSidebar: View {
.padding(16)
.softCard(radius: 24)
.padding(10)
.task { await model.loadMediaLibraries() }
}
}
private struct MediaLibrarySidebarRow: View {
let title: String
let icon: String
let isSelected: Bool
var status: String? = nil
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 9) {
Image(systemName: icon).frame(width: 20).foregroundStyle(isSelected ? AppTheme.orange : .secondary)
Text(title).font(.callout).lineLimit(1)
Spacer()
if let status { Text(status).font(.caption2.weight(.semibold)).foregroundStyle(AppTheme.orange) }
}
.padding(.horizontal, 9).frame(height: 32)
.background(isSelected ? AppTheme.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
+179 -93
View File
@@ -23,6 +23,7 @@ struct MediaLibraryPage: View {
@State private var showDeleteConfirmation = false
@State private var editingLibrary: MediaLibraryDefinition?
@State private var activeCollectionID: String?
@State private var mediaScanTask: Task<Void, Never>?
private var selectedLibrary: MediaLibraryDefinition? {
model.mediaLibraries.first { $0.id == selectedLibraryID }
@@ -80,34 +81,42 @@ struct MediaLibraryPage: View {
private var activeCollection: MediaCollectionGroup? { collectionGroups.first { $0.id == activeCollectionID } }
private var featuredItem: MediaLibraryItem? {
selectedItemID.flatMap { id in items.first { $0.id == id } }
?? visibleItems.first(where: { $0.backdropData != nil })
?? visibleItems.first
private var posterColumns: [GridItem] { [GridItem(.adaptive(minimum: 132, maximum: 172), spacing: 16)] }
private var wallTitle: String {
if let activeCollection { return activeCollection.name }
switch filter {
case .all: return "全部影视"
case .movies: return "电影"
case .series: return "剧集"
case .collections: return "自动合集"
case .unmatched: return "未识别资源"
}
}
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.07, green: 0.075, blue: 0.075), Color(red: 0.035, green: 0.038, blue: 0.04)], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
if model.mediaLibraries.isEmpty && !isLoadingCache {
AppBackdrop()
if model.mediaLibraryDestination == .tasks {
mediaTasksView
} else if model.mediaLibraries.isEmpty && !isLoadingCache {
emptyLibraryState
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if let featuredItem, filter != .collections || activeCollection != nil { featuredHero(featuredItem) }
libraryControls
VStack(spacing: 0) {
libraryControls
ScrollView {
libraryContent
}
}
}
}
.preferredColorScheme(.dark)
.sheet(isPresented: $showCreateLibrary) {
CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive in
Task {
let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive)
model.mediaLibraryDestination = .library(library.id)
selectedLibraryID = library.id
await scan(library)
startScan(library)
}
}
}
@@ -116,7 +125,7 @@ struct MediaLibraryPage: View {
Task {
let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, updatedAt: library.updatedAt)
await model.updateMediaLibrary(updated)
await scan(updated)
startScan(updated)
}
}
}
@@ -135,11 +144,18 @@ struct MediaLibraryPage: View {
}
.task { await initializeLibrary() }
.onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } }
.onChange(of: model.mediaLibraryDestination) { _, _ in Task { await loadDestination() } }
.onChange(of: filter) { _, value in if value != .collections { activeCollectionID = nil } }
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
.onReceive(model.$mediaLibraryLiveItems) { liveItems in
guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return }
items = liveItems
if model.mediaLibraryDestination == .all {
var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
liveItems.forEach { merged[$0.id] = $0 }
items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
} else {
guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return }
items = liveItems
}
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
}
@@ -156,38 +172,41 @@ struct MediaLibraryPage: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func featuredHero(_ item: MediaLibraryItem) -> some View {
ZStack(alignment: .bottomLeading) {
MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill)
.frame(maxWidth: .infinity).frame(height: 330).clipped()
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.94)], startPoint: .top, endPoint: .bottom)
LinearGradient(colors: [Color.black.opacity(0.78), .clear], startPoint: .leading, endPoint: .trailing)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text(item.mediaKind == .tv ? "剧集" : (item.mediaKind == .movie ? "电影" : "云盘视频"))
if !item.year.isEmpty { Text(item.year) }
let count = resourceCount(for: item)
if count > 1 { Text(item.mediaKind == .tv ? "\(count)" : "\(count) 个版本") }
}
.font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.72))
Text(item.title).font(.system(size: 34, weight: .bold)).foregroundStyle(.white).lineLimit(2)
if !item.overview.isEmpty {
Text(item.overview).font(.callout).foregroundStyle(.white.opacity(0.78)).lineLimit(3).frame(maxWidth: 620, alignment: .leading)
}
HStack(spacing: 8) {
if item.hasChineseAudio { MediaLanguageBadge(title: "中文音轨", icon: "waveform") }
if item.hasChineseSubtitle { MediaLanguageBadge(title: "中文字幕", icon: "captions.bubble") }
Text(item.file.formattedSize).font(.caption.monospacedDigit()).foregroundStyle(.white.opacity(0.58))
}
HStack(spacing: 10) {
Button { play(item) } label: { Label(resourceCount(for: item) > 1 && item.mediaKind == .tv ? "播放第 1 集" : "播放", systemImage: "play.fill") }
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }.buttonStyle(.bordered)
private var mediaTasksView: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("刮削任务").font(.title2.weight(.bold))
Text("扫描、识别、入库与资源目录写回").font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 34).padding(.bottom, 30)
.padding(.horizontal, 28).padding(.top, 26)
if let progress = activeProgress {
VStack(alignment: .leading, spacing: 14) {
HStack {
Label(progress.phase, systemImage: model.isScanningMediaLibrary ? "magnifyingglass" : "square.and.arrow.down")
.font(.headline)
Spacer()
Text("\(progress.completed)/\(progress.total)").font(.callout.monospacedDigit()).foregroundStyle(.secondary)
}
ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).tint(AppTheme.orange)
HStack {
Text(model.mediaLibraryLiveLibraryID.flatMap { id in model.mediaLibraries.first(where: { $0.id == id })?.name } ?? "影视库")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button(role: .destructive) {
if model.isScanningMediaLibrary { cancelScan() } else { model.cancelMediaMetadataWriteback() }
} label: { Label("停止", systemImage: "stop.fill") }.buttonStyle(.bordered)
}
}
.padding(18).softCard(radius: 8).padding(.horizontal, 28)
} else {
ContentUnavailableView("没有正在运行的任务", systemImage: "checkmark.circle", description: Text("从具体影视库页面启动扫描后,进度会显示在这里。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Spacer()
}
.frame(height: 330)
}
private var libraryControls: some View {
@@ -198,13 +217,9 @@ struct MediaLibraryPage: View {
.frame(width: 38, height: 38).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
}
VStack(alignment: .leading, spacing: 2) {
Picker("影视", selection: $selectedLibraryID) {
ForEach(model.mediaLibraries) { library in Text(library.name).tag(Optional(library.id)) }
}
.labelsHidden().frame(width: 220, alignment: .leading)
if let library = selectedLibrary {
Text("\(library.kind.title) · \(library.sources.count) 个目录").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Text(selectedLibrary?.name ?? "所有影视").font(.headline.weight(.semibold)).lineLimit(1)
Text(selectedLibrary.map { "\($0.kind.title) · \($0.sources.count) 个目录" } ?? "\(model.mediaLibraries.count) 个影视库")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
HStack(spacing: 7) {
@@ -212,16 +227,24 @@ struct MediaLibraryPage: View {
TextField("搜索影视库", text: $searchText).textFieldStyle(.plain)
if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") }.buttonStyle(.plain).foregroundStyle(.secondary) }
}
.padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 7))
.padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7))
Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库")
Button { if let library = selectedLibrary { Task { await scan(library) } } } label: {
if model.isScanningMediaLibrary || model.isWritingMediaMetadata { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") }
Button {
if model.isScanningMediaLibrary { cancelScan() }
else if model.isWritingMediaMetadata { model.cancelMediaMetadataWriteback() }
else if let library = selectedLibrary { startScan(library) }
} label: {
Image(systemName: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "stop.fill" : "arrow.triangle.2.circlepath")
}
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || model.isWritingMediaMetadata || selectedLibrary == nil)
.buttonStyle(.bordered).tint(model.isScanningMediaLibrary || model.isWritingMediaMetadata ? .red : nil)
.help(model.isScanningMediaLibrary ? "取消扫描" : (model.isWritingMediaMetadata ? "停止写回" : "扫描并刮削"))
.disabled(selectedLibrary == nil)
Menu {
Button { editingLibrary = selectedLibrary } label: { Label("管理影视库", systemImage: "slider.horizontal.3") }
.disabled(selectedLibrary == nil)
Divider()
Button("删除影视库", role: .destructive) { showDeleteConfirmation = true }
.disabled(selectedLibrary == nil)
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).help("影视库操作")
}
@@ -253,49 +276,54 @@ struct MediaLibraryPage: View {
@ViewBuilder private var libraryContent: some View {
if isLoadingCache && items.isEmpty {
VStack(spacing: 14) { ProgressView().controlSize(.large); Text("正在读取本地影视库…").font(.callout.weight(.medium)) }
.frame(maxWidth: .infinity).padding(.vertical, 90)
MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28)
} else if model.isScanningMediaLibrary && items.isEmpty {
VStack(spacing: 14) {
ProgressView().controlSize(.large)
Text(model.mediaLibraryScanProgress.phase).font(.callout.weight(.medium))
if model.mediaLibraryScanProgress.total > 0 {
Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity).padding(.vertical, 90)
MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28)
} else if !errorText.isEmpty && items.isEmpty {
ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity).padding(.vertical, 70)
} else if filter == .collections && activeCollection == nil && collectionGroups.isEmpty {
ContentUnavailableView("暂无自动合集", systemImage: "rectangle.stack", description: Text("至少有两部本地影片属于同一 TMDB 或 NFO 合集时会自动显示。"))
.frame(maxWidth: .infinity).padding(.vertical, 70)
} else if filter == .collections && activeCollection == nil {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180, maximum: 230), spacing: 18)], alignment: .leading, spacing: 24) {
ForEach(collectionGroups) { collection in
MediaCollectionCard(collection: collection) {
activeCollectionID = collection.id
selectedItemID = collection.items.first?.id
VStack(alignment: .leading, spacing: 18) {
Text("自动合集").font(.title2.weight(.bold))
LazyVGrid(columns: posterColumns, alignment: .leading, spacing: 24) {
ForEach(collectionGroups) { collection in
MediaCollectionCard(collection: collection) {
activeCollectionID = collection.id
selectedItemID = collection.items.first?.id
}
}
}
}
.padding(.horizontal, 28).padding(.bottom, 36)
.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36)
} else if visibleItems.isEmpty {
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
.frame(maxWidth: .infinity).padding(.vertical, 70)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 145, maximum: 190), spacing: 18)], alignment: .leading, spacing: 24) {
ForEach(visibleItems) { item in
MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) {
withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id }
} onOpen: { play(item) }
.contextMenu {
Button { play(item) } label: { Label("播放", systemImage: "play.fill") }
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
VStack(alignment: .leading, spacing: 18) {
HStack {
Text(wallTitle).font(.title2.weight(.bold))
Spacer()
if model.isScanningMediaLibrary { Text("新条目会自动加入").font(.caption).foregroundStyle(.secondary) }
}
LazyVGrid(columns: posterColumns, alignment: .leading, spacing: 24) {
ForEach(visibleItems) { item in
MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) {
withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id }
} onOpen: { play(item) }
.contextMenu {
Button { play(item) } label: { Label("播放", systemImage: "play.fill") }
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
}
}
if model.isScanningMediaLibrary {
ForEach(0..<min(6, max(2, model.mediaLibraryScanProgress.total - model.mediaLibraryScanProgress.completed)), id: \.self) { _ in MediaPosterSkeleton() }
}
}
}
.padding(.horizontal, 28).padding(.bottom, 36)
.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36)
}
}
@@ -305,17 +333,37 @@ struct MediaLibraryPage: View {
await model.loadMediaLibraries()
if let target = model.mediaLibraryTarget, target.isDirectory {
if let existing = model.mediaLibraries.first(where: { library in library.sources.contains { $0.rootID == target.id } }) {
model.mediaLibraryDestination = .library(existing.id)
selectedLibraryID = existing.id
items = await model.cachedMediaLibraryItems(libraryID: existing.id)
} else {
let library = await model.createMediaLibrary(name: target.name, rootID: target.id, rootPath: target.cloudPath, kind: .mixed, recursive: true)
model.mediaLibraryDestination = .library(library.id)
selectedLibraryID = library.id
await scan(library)
startScan(library)
}
return
}
if selectedLibraryID == nil { selectedLibraryID = model.mediaLibraries.first?.id }
await loadCachedItems()
await loadDestination()
}
@MainActor private func loadDestination() async {
isLoadingCache = true; errorText = ""
defer { isLoadingCache = false }
switch model.mediaLibraryDestination {
case .all:
selectedLibraryID = nil
var loaded: [MediaLibraryItem] = []
for library in model.mediaLibraries { loaded += await model.cachedMediaLibraryItems(libraryID: library.id) }
items = Array(Dictionary(grouping: loaded, by: \.id).compactMap { $0.value.first })
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
case .library(let id):
selectedLibraryID = id
items = await model.cachedMediaLibraryItems(libraryID: id)
case .tasks:
selectedLibraryID = nil
}
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
@MainActor private func loadCachedItems() async {
@@ -326,12 +374,25 @@ struct MediaLibraryPage: View {
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
@MainActor private func startScan(_ library: MediaLibraryDefinition) {
guard mediaScanTask == nil else { return }
mediaScanTask = Task {
await scan(library)
mediaScanTask = nil
}
}
@MainActor private func cancelScan() {
mediaScanTask?.cancel()
}
@MainActor private func scan(_ library: MediaLibraryDefinition) async {
errorText = ""
do {
items = try await model.scanMediaLibrary(library)
selectedItemID = items.first?.id
} catch { errorText = error.localizedDescription }
} catch is CancellationError { }
catch { errorText = error.localizedDescription }
}
private func resourceCount(for item: MediaLibraryItem) -> Int {
@@ -494,7 +555,7 @@ private struct MediaCollectionCard: View {
.foregroundStyle(.white).background(.black.opacity(0.76), in: Capsule()).padding(8)
}
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(isHovered ? 0.34 : 0.10), lineWidth: 1) }
Text(collection.name).font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
Text(collection.name).font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(2).frame(height: 36, alignment: .topLeading)
Text(collection.items.map(\.year).filter { !$0.isEmpty }.sorted().first ?? "自动合集")
.font(.caption).foregroundStyle(.secondary)
}
@@ -521,8 +582,8 @@ private struct MediaPosterCard: View {
if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) }
else if resourceCount > 1 { Text(item.mediaKind == .tv ? "\(resourceCount)" : "\(resourceCount) 版本").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) }
}
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? Color.orange : Color.white.opacity(isHovered ? 0.38 : 0.10), lineWidth: isSelected ? 2 : 1) }
Text(item.title).font(.callout.weight(.semibold)).lineLimit(1)
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? AppTheme.orange : Color.primary.opacity(isHovered ? 0.34 : 0.10), lineWidth: isSelected ? 2 : 1) }
Text(item.title).font(.callout.weight(.semibold)).lineLimit(2).frame(height: 36, alignment: .topLeading)
HStack(spacing: 6) {
Text(item.year.isEmpty ? "视频" : item.year)
if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") }
@@ -536,23 +597,48 @@ private struct MediaPosterCard: View {
}
}
private struct MediaLanguageBadge: View {
let title: String
let icon: String
private struct MediaPosterWallSkeleton: View {
let columns: [GridItem]
let count: Int
var body: some View {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.86))
LazyVGrid(columns: columns, alignment: .leading, spacing: 24) {
ForEach(0..<count, id: \.self) { _ in MediaPosterSkeleton() }
}
}
}
private struct MediaPosterSkeleton: View {
var body: some View {
VStack(alignment: .leading, spacing: 9) {
RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.075)).aspectRatio(2 / 3, contentMode: .fit)
RoundedRectangle(cornerRadius: 3).fill(Color.primary.opacity(0.10)).frame(height: 14)
RoundedRectangle(cornerRadius: 3).fill(Color.primary.opacity(0.06)).frame(width: 72, height: 11)
}
.accessibilityHidden(true)
}
}
private struct MediaArtwork: View {
@Environment(\.colorScheme) private var colorScheme
let data: Data?
let icon: String
let contentMode: ContentMode
var body: some View {
Group {
if let data, let image = NSImage(data: data) { Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) }
else { ZStack { LinearGradient(colors: [Color(red: 0.19, green: 0.22, blue: 0.20), Color(red: 0.08, green: 0.09, blue: 0.09)], startPoint: .topLeading, endPoint: .bottomTrailing); Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.white.opacity(0.28)) } }
else {
ZStack {
LinearGradient(
colors: colorScheme == .dark
? [Color(red: 0.18, green: 0.21, blue: 0.20), Color(red: 0.09, green: 0.10, blue: 0.11)]
: [Color(red: 0.88, green: 0.92, blue: 0.91), Color(red: 0.95, green: 0.89, blue: 0.84)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.secondary.opacity(0.55))
}
}
}
}
}