mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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
|
@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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user