Add view options and refresh actions to tvOS media browser

This commit is contained in:
Arkadiusz Fal
2026-04-17 20:53:30 +02:00
parent bece7b35c7
commit 55f27e7f54
2 changed files with 231 additions and 122 deletions

View File

@@ -24,6 +24,7 @@ struct MediaBrowserView: View {
@State private var showViewOptions = false @State private var showViewOptions = false
#if os(tvOS) #if os(tvOS)
@State private var unsupportedFile: MediaFile? @State private var unsupportedFile: MediaFile?
@FocusState private var firstFileFocused: Bool
#endif #endif
private var listStyle: VideoListStyle { private var listStyle: VideoListStyle {
@@ -59,6 +60,115 @@ struct MediaBrowserView: View {
} }
var body: some View { var body: some View {
content
#if !os(tvOS)
.navigationTitle(navigationTitle)
.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(
String(localized: "viewOptions.title"),
systemImage: showOnlyPlayable
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle"
)
}
.liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition)
}
#endif
}
.sheet(isPresented: $showViewOptions) {
MediaBrowserViewOptionsSheet(
sourceType: source.type,
sortOrder: $sortOrder,
sortAscending: $sortAscending,
showOnlyPlayable: $showOnlyPlayable
)
.liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition)
}
.task {
await loadFiles()
}
.onChange(of: sortOrder) { _, newValue in
savePreference(newValue.rawValue, forKey: "sortOrder")
}
.onChange(of: sortAscending) { _, newValue in
savePreference(newValue, forKey: "sortAscending")
}
.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 { Group {
if isLoading && files.isEmpty { if isLoading && files.isEmpty {
ProgressView() ProgressView()
@@ -86,60 +196,6 @@ struct MediaBrowserView: View {
fileList 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(
String(localized: "viewOptions.title"),
systemImage: showOnlyPlayable
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle"
)
}
.liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition)
}
#endif
}
.sheet(isPresented: $showViewOptions) {
MediaBrowserViewOptionsSheet(
sourceType: source.type,
sortOrder: $sortOrder,
sortAscending: $sortAscending,
showOnlyPlayable: $showOnlyPlayable
)
.liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition)
}
.task {
await loadFiles()
}
.onChange(of: sortOrder) { _, newValue in
savePreference(newValue.rawValue, forKey: "sortOrder")
}
.onChange(of: sortAscending) { _, newValue in
savePreference(newValue, forKey: "sortAscending")
}
.onChange(of: showOnlyPlayable) { _, newValue in
savePreference(newValue, forKey: "showOnlyPlayable")
}
} }
private var navigationTitle: String { private var navigationTitle: String {
@@ -149,41 +205,55 @@ struct MediaBrowserView: View {
return (currentPath as NSString).lastPathComponent return (currentPath as NSString).lastPathComponent
} }
private var fileList: some View { private var fileListBackground: Color {
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color) 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) { private var fileListScrollView: some View {
if file.isDirectory { ScrollView {
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: file.path, showOnlyPlayable: showOnlyPlayable)) { LazyVStack(spacing: 0) {
MediaFileRow(file: file, sortOrder: sortOrder) sectionCard {
} ForEach(Array(displayedFiles.enumerated()), id: \.element.id) { index, file in
.foregroundStyle(.primary) let isLast = index == displayedFiles.count - 1
} else if file.isPlayable { let isFirst = index == 0
playableFileRow(for: file)
} else { SourceListRow(isLast: isLast, listStyle: listStyle) {
#if os(tvOS) if file.isDirectory {
MediaFileTVOSUnsupportedButton(onTap: { unsupportedFile = file }) { NavigationLink(value: NavigationDestination.mediaBrowser(source, path: file.path, showOnlyPlayable: showOnlyPlayable)) {
MediaFileRow(file: file, sortOrder: sortOrder) MediaFileRow(file: file, sortOrder: sortOrder)
}
#else
MediaFileRow(file: file, sortOrder: sortOrder)
#endif
}
} }
.foregroundStyle(.primary)
} else if file.isPlayable {
playableFileRow(for: file)
} else {
#if os(tvOS)
MediaFileTVOSUnsupportedButton(onTap: { unsupportedFile = file }) {
MediaFileRow(file: file, sortOrder: sortOrder)
}
#else
MediaFileRow(file: file, sortOrder: sortOrder)
#endif
} }
} }
#if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstFileFocused))
#endif
} }
.padding(.top, 16)
} }
) }
#if os(tvOS) .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( .alert(
String(localized: "mediaBrowser.unsupportedFile.title"), String(localized: "mediaBrowser.unsupportedFile.title"),
isPresented: Binding( isPresented: Binding(
@@ -196,7 +266,11 @@ struct MediaBrowserView: View {
} message: { file in } message: { file in
Text(String(localized: "mediaBrowser.unsupportedFile.message \(file.name)")) Text(String(localized: "mediaBrowser.unsupportedFile.message \(file.name)"))
} }
#endif #else
fileListBackground
.ignoresSafeArea()
.overlay(fileListScrollView)
#endif
} }
@ViewBuilder @ViewBuilder
@@ -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 // MARK: - Preview
#Preview { #Preview {

View File

@@ -22,47 +22,11 @@ struct MediaBrowserViewOptionsSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form {
Section {
Toggle("mediaBrowser.viewOptions.showOnlyPlayable", isOn: $showOnlyPlayable)
PlatformMenuPicker(String(localized: "mediaBrowser.viewOptions.sortBy"), selection: $sortOrder) {
ForEach(availableSortOptions) { order in
Label(order.displayName, systemImage: order.systemImage)
.tag(order)
}
}
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))
}
}
#if os(tvOS) #if os(tvOS)
.scrollClipDisabled() listContent
.padding(.horizontal, 40)
.padding(.vertical, 24)
#else #else
.navigationTitle("mediaBrowser.viewOptions.title") formContent
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif #endif
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label(String(localized: "common.close"), systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
} }
#if os(iOS) #if os(iOS)
.presentationDetents([.height(280)]) .presentationDetents([.height(280)])
@@ -75,6 +39,62 @@ struct MediaBrowserViewOptionsSheet: View {
} }
} }
} }
@ViewBuilder
private var sharedOptions: some View {
Toggle("mediaBrowser.viewOptions.showOnlyPlayable", isOn: $showOnlyPlayable)
PlatformMenuPicker(String(localized: "mediaBrowser.viewOptions.sortBy"), selection: $sortOrder) {
ForEach(availableSortOptions) { order in
Label(order.displayName, systemImage: order.systemImage)
.tag(order)
}
}
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))
}
#if os(tvOS)
private var listContent: some View {
List {
Section {
sharedOptions
}
}
.scrollClipDisabled()
.padding(.horizontal, 40)
.padding(.vertical, 24)
}
#endif
private var formContent: some View {
Form {
Section {
sharedOptions
}
}
.navigationTitle("mediaBrowser.viewOptions.title")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label(String(localized: "common.close"), systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
}
} }
// MARK: - Preview // MARK: - Preview