mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
Add view options and refresh actions to tvOS media browser
This commit is contained in:
@@ -24,6 +24,7 @@ struct MediaBrowserView: View {
|
||||
@State private var showViewOptions = false
|
||||
#if os(tvOS)
|
||||
@State private var unsupportedFile: MediaFile?
|
||||
@FocusState private var firstFileFocused: Bool
|
||||
#endif
|
||||
|
||||
private var listStyle: VideoListStyle {
|
||||
@@ -59,35 +60,9 @@ struct MediaBrowserView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if files.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "mediaBrowser.emptyFolder"), systemImage: "folder")
|
||||
} description: {
|
||||
Text(String(localized: "mediaBrowser.emptyFolder.description"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
fileList
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
content
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(navigationTitle)
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
#endif
|
||||
.toolbar {
|
||||
@@ -140,6 +115,87 @@ struct MediaBrowserView: View {
|
||||
.onChange(of: showOnlyPlayable) { _, newValue in
|
||||
savePreference(newValue, forKey: "showOnlyPlayable")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: displayedFiles.isEmpty) { wasEmpty, isEmpty in
|
||||
if wasEmpty && !isEmpty {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
firstFileFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(tvOS)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "folder",
|
||||
title: navigationTitle
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
if !(isLoading && files.isEmpty) {
|
||||
HStack(spacing: 24) {
|
||||
Button {
|
||||
Task { await loadFiles() }
|
||||
} label: {
|
||||
Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "viewOptions.title"),
|
||||
systemImage: showOnlyPlayable
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle"
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
.focusSection()
|
||||
}
|
||||
inner
|
||||
.focusSection()
|
||||
}
|
||||
}
|
||||
#else
|
||||
inner
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var inner: 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)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if files.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "mediaBrowser.emptyFolder"), systemImage: "folder")
|
||||
} description: {
|
||||
Text(String(localized: "mediaBrowser.emptyFolder.description"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
fileList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
@@ -149,15 +205,17 @@ struct MediaBrowserView: View {
|
||||
return (currentPath as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
private var fileList: some View {
|
||||
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
private var fileListBackground: Color {
|
||||
listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color
|
||||
}
|
||||
|
||||
private var fileListScrollView: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
sectionCard {
|
||||
ForEach(Array(displayedFiles.enumerated()), id: \.element.id) { index, file in
|
||||
let isLast = index == displayedFiles.count - 1
|
||||
let isFirst = index == 0
|
||||
|
||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||
if file.isDirectory {
|
||||
@@ -177,13 +235,25 @@ struct MediaBrowserView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstFileFocused))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fileList: some View {
|
||||
#if os(tvOS)
|
||||
// On tvOS do NOT ignoreSafeArea — otherwise the ScrollView extends
|
||||
// under TVSidebarDetailContainer's 400pt leading inset, and left-press
|
||||
// from a file row escapes to the outer sidebarAdaptable TabView
|
||||
// instead of reaching the inline Refresh / View Options buttons.
|
||||
fileListBackground
|
||||
.overlay(fileListScrollView)
|
||||
.alert(
|
||||
String(localized: "mediaBrowser.unsupportedFile.title"),
|
||||
isPresented: Binding(
|
||||
@@ -196,6 +266,10 @@ struct MediaBrowserView: View {
|
||||
} message: { file in
|
||||
Text(String(localized: "mediaBrowser.unsupportedFile.message \(file.name)"))
|
||||
}
|
||||
#else
|
||||
fileListBackground
|
||||
.ignoresSafeArea()
|
||||
.overlay(fileListScrollView)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -388,6 +462,21 @@ struct MediaBrowserView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#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 {
|
||||
|
||||
@@ -22,8 +22,26 @@ struct MediaBrowserViewOptionsSheet: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
listContent
|
||||
#else
|
||||
formContent
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.height(280)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Reset sort order if current selection is not available for this source type
|
||||
if !availableSortOptions.contains(sortOrder) {
|
||||
sortOrder = .name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sharedOptions: some View {
|
||||
Toggle("mediaBrowser.viewOptions.showOnlyPlayable", isOn: $showOnlyPlayable)
|
||||
PlatformMenuPicker(String(localized: "mediaBrowser.viewOptions.sortBy"), selection: $sortOrder) {
|
||||
ForEach(availableSortOptions) { order in
|
||||
@@ -42,17 +60,30 @@ struct MediaBrowserViewOptionsSheet: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var listContent: some View {
|
||||
List {
|
||||
Section {
|
||||
sharedOptions
|
||||
}
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.vertical, 24)
|
||||
#else
|
||||
}
|
||||
#endif
|
||||
|
||||
private var formContent: some View {
|
||||
Form {
|
||||
Section {
|
||||
sharedOptions
|
||||
}
|
||||
}
|
||||
.navigationTitle("mediaBrowser.viewOptions.title")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(role: .cancel) {
|
||||
@@ -64,17 +95,6 @@ struct MediaBrowserViewOptionsSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.height(280)])
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user