mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
Add preset-driven details panel pages on tvOS
TVDetailsPanel now renders the active tvOS preset's panel pages (info, comments, related, chapters, queue; tracks stays with the quality panel) with runtime hiding of empty pages, a segmented picker for up to four pages and a focusable capsule row above that, and Info element toggles honored. Comments unify on the PlayerState loader; the tvOS queue list is extracted from QueueManagementSheet into TVQueueListView and reused as the queue page. New related/chapters focus-based pages: selecting a related video plays it and dismisses the panel, chapters seek and keep the panel open. New minimal Settings > Player Panels screen edits the active tvOS preset via the existing service: duplicate-to-edit, page toggles with last-page guard, Move Up/Down reordering, and Info element toggles.
This commit is contained in:
@@ -7577,6 +7577,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.duplicatePreset" : {
|
||||
"comment" : "tvOS button duplicating the active preset so it can be edited",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Duplicate Preset to Edit"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.elements.header" : {
|
||||
"comment" : "Details panel page editor section header",
|
||||
"localizations" : {
|
||||
@@ -7643,6 +7654,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.moveDown" : {
|
||||
"comment" : "tvOS page editor button moving the page later in the order",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Move Down"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.moveUp" : {
|
||||
"comment" : "tvOS page editor button moving the page earlier in the order",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Move Up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.page.enabled" : {
|
||||
"comment" : "Details panel page editor toggle",
|
||||
"localizations" : {
|
||||
@@ -15126,6 +15159,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playerPanels.title" : {
|
||||
"comment" : "tvOS settings row for the player details panel pages",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Player Panels"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.privacy.history.account.footer %@" : {
|
||||
"comment" : "Footer telling the user watched videos are mirrored to the selected account's server history",
|
||||
"localizations" : {
|
||||
|
||||
@@ -16,26 +16,6 @@ struct QueueManagementSheet: View {
|
||||
/// nil, falls back to `@Environment(\.dismiss)`.
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
#if os(tvOS)
|
||||
/// Identifies which row should receive focus when the inline panel appears.
|
||||
/// Necessary because tvOS doesn't auto-focus an inline overlay the way it
|
||||
/// does for `fullScreenCover`.
|
||||
enum InlinePanelFocus: Hashable {
|
||||
case history(String)
|
||||
case nowPlaying
|
||||
case upNext(String)
|
||||
}
|
||||
|
||||
@FocusState var inlinePanelFocus: InlinePanelFocus?
|
||||
|
||||
private var inlinePanelInitialFocusTarget: InlinePanelFocus? {
|
||||
if let first = history.first { return .history(first.id) }
|
||||
if playerState?.currentVideo != nil { return .nowPlaying }
|
||||
if let first = queue.first { return .upNext(first.id) }
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
private func performDismiss() {
|
||||
if let onDismiss {
|
||||
onDismiss()
|
||||
@@ -98,11 +78,6 @@ struct QueueManagementSheet: View {
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.onExitCommand { performDismiss() }
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||
inlinePanelFocus = inlinePanelInitialFocusTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -199,113 +174,13 @@ struct QueueManagementSheet: View {
|
||||
|
||||
#if os(tvOS)
|
||||
private var tvOSQueueListView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
if !history.isEmpty {
|
||||
sectionHeader(
|
||||
String(localized: "queue.section.previously"),
|
||||
count: history.count
|
||||
)
|
||||
VStack(spacing: 20) {
|
||||
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
|
||||
QueueItemRow(
|
||||
queuedVideo: historyItem,
|
||||
index: nil,
|
||||
isCurrentlyPlaying: false,
|
||||
onRemove: { },
|
||||
onTap: { playFromHistory(at: index) }
|
||||
)
|
||||
.focused($inlinePanelFocus, equals: .history(historyItem.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let currentVideo = playerState?.currentVideo {
|
||||
sectionHeader(String(localized: "queue.section.nowPlaying"))
|
||||
.id("now-playing")
|
||||
|
||||
let nowPlayingItem = QueuedVideo(
|
||||
video: currentVideo,
|
||||
stream: playerState?.currentStream,
|
||||
queueSource: nil
|
||||
)
|
||||
QueueItemRow(
|
||||
queuedVideo: nowPlayingItem,
|
||||
index: nil,
|
||||
isCurrentlyPlaying: true,
|
||||
onRemove: { },
|
||||
onTap: { }
|
||||
)
|
||||
.focused($inlinePanelFocus, equals: .nowPlaying)
|
||||
}
|
||||
|
||||
if !queue.isEmpty {
|
||||
sectionHeader(
|
||||
String(localized: "queue.section.upNext"),
|
||||
count: queue.count
|
||||
)
|
||||
VStack(spacing: 20) {
|
||||
ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in
|
||||
QueueItemRow(
|
||||
queuedVideo: queuedVideo,
|
||||
index: index + 1,
|
||||
isCurrentlyPlaying: false,
|
||||
onRemove: {
|
||||
withAnimation {
|
||||
queueManager?.removeFromQueue(id: queuedVideo.id)
|
||||
}
|
||||
},
|
||||
onTap: { playVideo(at: index) }
|
||||
)
|
||||
.focused($inlinePanelFocus, equals: .upNext(queuedVideo.id))
|
||||
}
|
||||
|
||||
if hasMoreItems {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isLoadingMore {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
Task { try? await queueManager?.loadMoreQueueItems() }
|
||||
} label: {
|
||||
Text(String(localized: "queue.sheet.loadMore"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 80)
|
||||
.padding(.vertical, 24)
|
||||
TVQueueListView(
|
||||
autoFocusOnAppear: true,
|
||||
onDidSelectVideo: {
|
||||
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
||||
performDismiss()
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
proxy.scrollTo("now-playing", anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String, count: Int? = nil) -> some View {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let count {
|
||||
Text(String(localized: "queue.section.count \(count)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -357,21 +232,6 @@ struct QueueManagementSheet: View {
|
||||
playerState.queueMode = modes[nextIndex]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
#if os(tvOS)
|
||||
private func playVideo(at index: Int) {
|
||||
QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment)
|
||||
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
||||
performDismiss()
|
||||
}
|
||||
|
||||
private func playFromHistory(at index: Int) {
|
||||
QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment)
|
||||
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
||||
performDismiss()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -10,21 +10,26 @@ import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
/// Details panel that slides up from the bottom showing video information.
|
||||
/// Pages come from the active tvOS preset's panel settings; the tracks page
|
||||
/// is not offered on tvOS (the quality panel covers it).
|
||||
struct TVDetailsPanel: View {
|
||||
let video: Video?
|
||||
let initialTab: TVDetailsTab
|
||||
let initialPage: PanelPageType
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
/// Tab selection for Info / Comments.
|
||||
@State private var selectedTab: TVDetailsTab
|
||||
/// Currently selected page.
|
||||
@State private var selectedPage: PanelPageType
|
||||
|
||||
init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) {
|
||||
/// Active preset's panel settings; refreshed from the layout service.
|
||||
@State private var panelSettings: PanelSettings = PanelSettings.cached
|
||||
|
||||
init(video: Video?, initialPage: PanelPageType = .info, onDismiss: @escaping () -> Void) {
|
||||
self.video = video
|
||||
self.initialTab = initialTab
|
||||
self.initialPage = initialPage
|
||||
self.onDismiss = onDismiss
|
||||
_selectedTab = State(initialValue: initialTab)
|
||||
_selectedPage = State(initialValue: initialPage)
|
||||
}
|
||||
|
||||
/// Focus state for interactive elements.
|
||||
@@ -33,36 +38,68 @@ struct TVDetailsPanel: View {
|
||||
/// Whether description scroll is locked (prevents focus from leaving description).
|
||||
@State private var isDescriptionScrollLocked = false
|
||||
|
||||
/// Comments state
|
||||
@State private var comments: [Comment] = []
|
||||
@State private var commentsState: CommentsLoadState = .idle
|
||||
@State private var commentsContinuation: String?
|
||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
|
||||
// MARK: - Page Visibility
|
||||
|
||||
/// Config enables a page; runtime hides pages with nothing to show.
|
||||
private func isRuntimeVisible(_ pageType: PanelPageType) -> Bool {
|
||||
switch pageType {
|
||||
case .info:
|
||||
return true
|
||||
case .comments:
|
||||
return video?.supportsComments == true
|
||||
&& playerState?.commentsState != .disabled
|
||||
case .related:
|
||||
return !(video?.relatedVideos ?? []).isEmpty
|
||||
case .chapters:
|
||||
return !(playerState?.chapters ?? []).isEmpty
|
||||
case .queue:
|
||||
return appEnvironment?.settingsManager.queueEnabled ?? true
|
||||
case .tracks:
|
||||
// The tvOS quality panel covers tracks; never offered here.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Enabled pages that currently have content, in configured order.
|
||||
/// Falls back to Info so the panel is never empty.
|
||||
private var visiblePages: [PanelPageType] {
|
||||
let pages = panelSettings.enabledPages()
|
||||
.map(\.pageType)
|
||||
.filter { isRuntimeVisible($0) }
|
||||
return pages.isEmpty ? [.info] : pages
|
||||
}
|
||||
|
||||
/// The page to render: the selection when visible, else the first page.
|
||||
private var effectivePage: PanelPageType {
|
||||
let pages = visiblePages
|
||||
return pages.contains(selectedPage) ? selectedPage : (pages.first ?? .info)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let pages = visiblePages
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Tab picker (hidden when description scroll is locked)
|
||||
if !isDescriptionScrollLocked {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("player.tab.info").tag(TVDetailsTab.info)
|
||||
if video?.supportsComments == true {
|
||||
Text("player.tab.comments").tag(TVDetailsTab.comments)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 40)
|
||||
.padding(.bottom, 20)
|
||||
.focused($focusedItem, equals: .tabPicker)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
// Page switcher (hidden when description scroll is locked)
|
||||
if pages.count >= 2, !isDescriptionScrollLocked {
|
||||
pageSwitcher(pages: pages)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
// Content based on selected tab
|
||||
// Content for the selected page
|
||||
Group {
|
||||
switch selectedTab {
|
||||
case .info:
|
||||
switch effectivePage {
|
||||
case .info, .tracks:
|
||||
infoContent
|
||||
case .comments:
|
||||
commentsContent
|
||||
case .related:
|
||||
TVRelatedPanelList(video: video, onDidSelectVideo: onDismiss)
|
||||
case .chapters:
|
||||
TVChaptersPanelList()
|
||||
case .queue:
|
||||
TVQueueListView(horizontalPadding: 0)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
@@ -87,28 +124,88 @@ struct TVDetailsPanel: View {
|
||||
.onAppear {
|
||||
focusedItem = .tabPicker
|
||||
}
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
// Reset scroll lock when switching tabs
|
||||
.task {
|
||||
await reloadPanelSettings()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
|
||||
Task { await reloadPanelSettings() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
|
||||
Task { await reloadPanelSettings() }
|
||||
}
|
||||
.onChange(of: selectedPage) { _, _ in
|
||||
// Reset scroll lock when switching pages
|
||||
if isDescriptionScrollLocked {
|
||||
isDescriptionScrollLocked = false
|
||||
}
|
||||
// Reset comments state when switching to comments tab
|
||||
if newTab == .comments && commentsState == .idle {
|
||||
// Comments will load via TVCommentsListView's .task
|
||||
}
|
||||
}
|
||||
.onChange(of: video?.supportsComments) { _, supportsComments in
|
||||
// If current video doesn't support comments, switch to info tab
|
||||
if supportsComments == false && selectedTab == .comments {
|
||||
selectedTab = .info
|
||||
.onChange(of: video?.id) { _, _ in
|
||||
// New video: fall back to the first configured page.
|
||||
if let first = visiblePages.first {
|
||||
selectedPage = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page Switcher
|
||||
|
||||
@ViewBuilder
|
||||
private func pageSwitcher(pages: [PanelPageType]) -> some View {
|
||||
// Segmented control fits up to 4 pages in the half-screen panel;
|
||||
// above that a focusable button row keeps labels legible.
|
||||
if pages.count <= 4 {
|
||||
Picker("", selection: $selectedPage) {
|
||||
ForEach(pages, id: \.self) { page in
|
||||
Text(page.displayName).tag(page)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 40)
|
||||
.padding(.bottom, 20)
|
||||
.focused($focusedItem, equals: .tabPicker)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(pages, id: \.self) { page in
|
||||
Button {
|
||||
selectedPage = page
|
||||
} label: {
|
||||
Label(page.displayName, systemImage: page.systemImage)
|
||||
.font(.callout)
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
.buttonStyle(TVPageSwitcherButtonStyle(isSelected: effectivePage == page))
|
||||
.focused($focusedItem, equals: .pageButton(page))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 40)
|
||||
.padding(.bottom, 20)
|
||||
.focusSection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Settings
|
||||
|
||||
private func reloadPanelSettings() async {
|
||||
guard let service = appEnvironment?.playerControlsLayoutService else { return }
|
||||
let layout = await service.activeLayout()
|
||||
let settings = layout.effectivePanelSettings
|
||||
panelSettings = settings
|
||||
PanelSettings.cached = settings
|
||||
}
|
||||
|
||||
// MARK: - Info Content
|
||||
|
||||
private var infoSettings: InfoPageSettings {
|
||||
panelSettings.configuration(for: .info)?.infoSettings ?? InfoPageSettings()
|
||||
}
|
||||
|
||||
private var infoContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
let settings = infoSettings
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
// Top section with title, channel, stats (hidden when description expanded)
|
||||
if !isDescriptionScrollLocked {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
@@ -120,10 +217,14 @@ struct TVDetailsPanel: View {
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Channel info row
|
||||
channelRow
|
||||
if settings.effectiveShowChannelRow {
|
||||
channelRow
|
||||
}
|
||||
|
||||
// Stats row
|
||||
statsRow
|
||||
if settings.effectiveShowStatsRow {
|
||||
statsRow
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -131,7 +232,8 @@ struct TVDetailsPanel: View {
|
||||
}
|
||||
|
||||
// Description - expands to fill when locked
|
||||
if let description = video?.description, !description.isEmpty {
|
||||
if settings.effectiveShowDescription,
|
||||
let description = video?.description, !description.isEmpty {
|
||||
TVScrollableDescription(
|
||||
description: description,
|
||||
isScrollLocked: $isDescriptionScrollLocked,
|
||||
@@ -265,13 +367,8 @@ struct TVDetailsPanel: View {
|
||||
private var commentsContent: some View {
|
||||
ScrollView {
|
||||
if let videoID = video?.id.videoID {
|
||||
TVCommentsListView(
|
||||
videoID: videoID,
|
||||
comments: $comments,
|
||||
commentsState: $commentsState,
|
||||
commentsContinuation: $commentsContinuation
|
||||
)
|
||||
.padding(.vertical, 16)
|
||||
TVCommentsListView(videoID: videoID)
|
||||
.padding(.vertical, 16)
|
||||
} else {
|
||||
Text(String(localized: "comments.unavailable"))
|
||||
.font(.body)
|
||||
@@ -292,19 +389,37 @@ struct TVDetailsPanel: View {
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Tabs for the details panel.
|
||||
enum TVDetailsTab: String, CaseIterable {
|
||||
case info
|
||||
case comments
|
||||
}
|
||||
|
||||
/// Focus items for the details panel.
|
||||
enum TVDetailsFocusItem: Hashable {
|
||||
case tabPicker
|
||||
case pageButton(PanelPageType)
|
||||
case channelButton
|
||||
case description
|
||||
}
|
||||
|
||||
/// Button style for the page switcher row used when more than four pages are
|
||||
/// enabled. Solid focus fill (materials are invisible over video on Apple TV
|
||||
/// HD); the selected page keeps a subtle persistent fill.
|
||||
struct TVPageSwitcherButtonStyle: ButtonStyle {
|
||||
let isSelected: Bool
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(isFocused ? Color.black : (isSelected ? .white : .white.opacity(0.6)))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isFocused ? Color.white.opacity(0.92) : (isSelected ? Color.white.opacity(0.2) : .clear))
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
|
||||
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable description view with click-to-lock scrolling.
|
||||
/// When locked, expands to fill available space for easier reading.
|
||||
struct TVScrollableDescription: View {
|
||||
@@ -430,19 +545,22 @@ struct TVDescriptionButtonStyle: ButtonStyle {
|
||||
|
||||
/// tvOS comments list with focusable comment rows.
|
||||
/// Each comment is focusable to allow one-by-one navigation.
|
||||
/// Backed by the unified `PlayerState` comments loader shared with iOS/macOS,
|
||||
/// so a page opened here reuses (and feeds) the same fetched comments.
|
||||
struct TVCommentsListView: View {
|
||||
let videoID: String
|
||||
@Binding var comments: [Comment]
|
||||
@Binding var commentsState: CommentsLoadState
|
||||
@Binding var commentsContinuation: String?
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
private var contentService: ContentService? { appEnvironment?.contentService }
|
||||
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
|
||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
|
||||
private var comments: [Comment] { playerState?.comments ?? [] }
|
||||
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
|
||||
|
||||
private var canLoadMore: Bool {
|
||||
commentsContinuation != nil && commentsState == .loaded
|
||||
playerState?.commentsContinuation != nil && commentsState == .loaded
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -544,39 +662,15 @@ struct TVCommentsListView: View {
|
||||
}
|
||||
|
||||
private func loadComments() async {
|
||||
guard commentsState == .idle else { return }
|
||||
guard let contentService, let instancesManager else { return }
|
||||
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
|
||||
|
||||
commentsState = .loading
|
||||
|
||||
do {
|
||||
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil)
|
||||
comments = page.comments
|
||||
commentsContinuation = page.continuation
|
||||
commentsState = .loaded
|
||||
} catch let error as APIError where error == .commentsDisabled {
|
||||
commentsState = .disabled
|
||||
} catch {
|
||||
commentsState = .error
|
||||
}
|
||||
guard commentsState == .idle || commentsState == .error else { return }
|
||||
guard let playerState, let contentService, let instancesManager else { return }
|
||||
await playerState.loadComments(contentService: contentService, instancesManager: instancesManager)
|
||||
}
|
||||
|
||||
private func loadMoreComments() async {
|
||||
guard canLoadMore else { return }
|
||||
guard let contentService, let instancesManager else { return }
|
||||
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
|
||||
|
||||
commentsState = .loadingMore
|
||||
|
||||
do {
|
||||
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: commentsContinuation)
|
||||
comments.append(contentsOf: page.comments)
|
||||
commentsContinuation = page.continuation
|
||||
commentsState = .loaded
|
||||
} catch {
|
||||
commentsState = .loaded
|
||||
}
|
||||
guard let playerState, let contentService, let instancesManager else { return }
|
||||
await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
170
Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift
Normal file
170
Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift
Normal file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// TVDetailsPanelPages.swift
|
||||
// Yattee
|
||||
//
|
||||
// Related videos and chapters pages for the tvOS player details panel.
|
||||
//
|
||||
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Related Videos
|
||||
|
||||
/// Focus-based list of videos related to the current one. Selecting a video
|
||||
/// plays it immediately and dismisses the panel.
|
||||
struct TVRelatedPanelList: View {
|
||||
let video: Video?
|
||||
let onDidSelectVideo: () -> Void
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
private var relatedVideos: [Video] { video?.relatedVideos ?? [] }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
ForEach(relatedVideos) { relatedVideo in
|
||||
TVRelatedVideoRow(video: relatedVideo) {
|
||||
playVideo(relatedVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
}
|
||||
|
||||
private func playVideo(_ video: Video) {
|
||||
guard let playerService = appEnvironment?.playerService else { return }
|
||||
Task {
|
||||
await playerService.play(video: video)
|
||||
}
|
||||
onDidSelectVideo()
|
||||
}
|
||||
}
|
||||
|
||||
/// Focusable row for a related video: thumbnail, title, channel.
|
||||
private struct TVRelatedVideoRow: View {
|
||||
let video: Video
|
||||
let onTap: () -> Void
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 16) {
|
||||
DeArrowVideoThumbnail(
|
||||
video: video,
|
||||
cornerRadius: 6,
|
||||
duration: video.formattedDuration
|
||||
)
|
||||
.frame(width: 160, height: 90)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(video.author.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapters
|
||||
|
||||
/// Focus-based chapter list; selecting a chapter seeks and keeps the panel
|
||||
/// open so nearby chapters stay reachable.
|
||||
struct TVChaptersPanelList: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
private var playerService: PlayerService? { appEnvironment?.playerService }
|
||||
private var playerState: PlayerState? { playerService?.state }
|
||||
|
||||
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
|
||||
private var currentTime: TimeInterval { playerState?.currentTime ?? 0 }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(chapters) { chapter in
|
||||
TVChapterRow(
|
||||
chapter: chapter,
|
||||
isActive: isChapterActive(chapter),
|
||||
onTap: {
|
||||
Task {
|
||||
await playerService?.seek(to: chapter.startTime)
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(chapter.id)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.onAppear {
|
||||
if let active = chapters.first(where: { isChapterActive($0) }) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
proxy.scrollTo(active.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isChapterActive(_ chapter: VideoChapter) -> Bool {
|
||||
let nextChapter = chapters.first { $0.startTime > chapter.startTime }
|
||||
let endTime = nextChapter?.startTime ?? .infinity
|
||||
return currentTime >= chapter.startTime && currentTime < endTime
|
||||
}
|
||||
}
|
||||
|
||||
/// Focusable row for a chapter: title, start time, and an active indicator.
|
||||
private struct TVChapterRow: View {
|
||||
let chapter: VideoChapter
|
||||
let isActive: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 16) {
|
||||
Text(chapter.formattedStartTime)
|
||||
.font(.subheadline)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.white.opacity(0.6)))
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(chapter.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(isActive ? .semibold : .regular)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isActive {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -45,8 +45,8 @@ struct TVPlayerView: View {
|
||||
/// Whether the details panel is shown.
|
||||
@State private var isDetailsPanelVisible = false
|
||||
|
||||
/// Initial tab for the details panel when opened.
|
||||
@State private var detailsPanelInitialTab: TVDetailsTab = .info
|
||||
/// Initial page for the details panel when opened.
|
||||
@State private var detailsPanelInitialPage: PanelPageType = .info
|
||||
|
||||
/// Whether user is scrubbing the progress bar.
|
||||
@State private var isScrubbing = false
|
||||
@@ -279,7 +279,7 @@ struct TVPlayerView: View {
|
||||
Spacer(minLength: 0)
|
||||
TVDetailsPanel(
|
||||
video: playerState?.currentVideo,
|
||||
initialTab: detailsPanelInitialTab,
|
||||
initialPage: detailsPanelInitialPage,
|
||||
onDismiss: { hideDetailsPanel() }
|
||||
)
|
||||
.frame(width: geo.size.width / 2)
|
||||
@@ -730,9 +730,9 @@ struct TVPlayerView: View {
|
||||
|
||||
// MARK: - Details Panel
|
||||
|
||||
private func showDetailsPanel(tab: TVDetailsTab = .info) {
|
||||
private func showDetailsPanel(tab: PanelPageType = .info) {
|
||||
stopControlsTimer()
|
||||
detailsPanelInitialTab = tab
|
||||
detailsPanelInitialPage = tab
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
isDetailsPanelVisible = true
|
||||
controlsVisible = false
|
||||
|
||||
180
Yattee/Views/Player/tvOS/TVQueueListView.swift
Normal file
180
Yattee/Views/Player/tvOS/TVQueueListView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// TVQueueListView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Focus-based queue list (history / now playing / up next) shared by the
|
||||
// half-screen queue panel and the details panel queue page.
|
||||
//
|
||||
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
struct TVQueueListView: View {
|
||||
/// Horizontal content padding. The standalone half-screen panel uses 80;
|
||||
/// the details panel page passes its own smaller inset.
|
||||
var horizontalPadding: CGFloat = 80
|
||||
|
||||
/// Whether the list should grab focus shortly after appearing. Needed for
|
||||
/// the inline half-screen panel, which tvOS doesn't auto-focus the way it
|
||||
/// does a `fullScreenCover`; the details panel page keeps focus on its
|
||||
/// page switcher instead.
|
||||
var autoFocusOnAppear = false
|
||||
|
||||
/// Called after a row selection triggers playback.
|
||||
var onDidSelectVideo: (() -> Void)?
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
/// Identifies which row should receive focus when the list appears.
|
||||
enum QueueListFocus: Hashable {
|
||||
case history(String)
|
||||
case nowPlaying
|
||||
case upNext(String)
|
||||
}
|
||||
|
||||
@FocusState private var listFocus: QueueListFocus?
|
||||
|
||||
private var initialFocusTarget: QueueListFocus? {
|
||||
if let first = history.first { return .history(first.id) }
|
||||
if playerState?.currentVideo != nil { return .nowPlaying }
|
||||
if let first = queue.first { return .upNext(first.id) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var queueManager: QueueManager? { appEnvironment?.queueManager }
|
||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
|
||||
private var queue: [QueuedVideo] { playerState?.queue ?? [] }
|
||||
private var history: [QueuedVideo] { playerState?.history ?? [] }
|
||||
private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false }
|
||||
private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
if !history.isEmpty {
|
||||
sectionHeader(
|
||||
String(localized: "queue.section.previously"),
|
||||
count: history.count
|
||||
)
|
||||
VStack(spacing: 20) {
|
||||
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
|
||||
QueueItemRow(
|
||||
queuedVideo: historyItem,
|
||||
index: nil,
|
||||
isCurrentlyPlaying: false,
|
||||
onRemove: { },
|
||||
onTap: { playFromHistory(at: index) }
|
||||
)
|
||||
.focused($listFocus, equals: .history(historyItem.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let currentVideo = playerState?.currentVideo {
|
||||
sectionHeader(String(localized: "queue.section.nowPlaying"))
|
||||
.id("now-playing")
|
||||
|
||||
let nowPlayingItem = QueuedVideo(
|
||||
video: currentVideo,
|
||||
stream: playerState?.currentStream,
|
||||
queueSource: nil
|
||||
)
|
||||
QueueItemRow(
|
||||
queuedVideo: nowPlayingItem,
|
||||
index: nil,
|
||||
isCurrentlyPlaying: true,
|
||||
onRemove: { },
|
||||
onTap: { }
|
||||
)
|
||||
.focused($listFocus, equals: .nowPlaying)
|
||||
}
|
||||
|
||||
if !queue.isEmpty {
|
||||
sectionHeader(
|
||||
String(localized: "queue.section.upNext"),
|
||||
count: queue.count
|
||||
)
|
||||
VStack(spacing: 20) {
|
||||
ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in
|
||||
QueueItemRow(
|
||||
queuedVideo: queuedVideo,
|
||||
index: index + 1,
|
||||
isCurrentlyPlaying: false,
|
||||
onRemove: {
|
||||
withAnimation {
|
||||
queueManager?.removeFromQueue(id: queuedVideo.id)
|
||||
}
|
||||
},
|
||||
onTap: { playVideo(at: index) }
|
||||
)
|
||||
.focused($listFocus, equals: .upNext(queuedVideo.id))
|
||||
}
|
||||
|
||||
if hasMoreItems {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isLoadingMore {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
Task { try? await queueManager?.loadMoreQueueItems() }
|
||||
} label: {
|
||||
Text(String(localized: "queue.sheet.loadMore"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
proxy.scrollTo("now-playing", anchor: .center)
|
||||
}
|
||||
if autoFocusOnAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||
listFocus = initialFocusTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String, count: Int? = nil) -> some View {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let count {
|
||||
Text(String(localized: "queue.section.count \(count)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func playVideo(at index: Int) {
|
||||
QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment)
|
||||
onDidSelectVideo?()
|
||||
}
|
||||
|
||||
private func playFromHistory(at index: Int) {
|
||||
QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment)
|
||||
onDidSelectVideo?()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// TVPlayerPanelsSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Minimal tvOS settings for the player details panel pages. Edits the
|
||||
// active tvOS preset via the shared layout service; CloudKit preset sync is
|
||||
// device-class filtered, so tvOS panels cannot be configured from iOS/macOS.
|
||||
//
|
||||
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
struct TVPlayerPanelsSettingsView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var viewModel: PlayerControlsSettingsViewModel?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let viewModel {
|
||||
TVPlayerPanelsContent(viewModel: viewModel)
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel == nil, let appEnvironment {
|
||||
let vm = PlayerControlsSettingsViewModel(
|
||||
layoutService: appEnvironment.playerControlsLayoutService,
|
||||
settingsManager: appEnvironment.settingsManager
|
||||
)
|
||||
viewModel = vm
|
||||
await vm.loadPresets()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private struct TVPlayerPanelsContent: View {
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
/// Pages offered on tvOS; tracks is covered by the quality panel.
|
||||
private var tvOSPages: [PanelPageConfiguration] {
|
||||
viewModel.panelPages.filter { $0.pageType != .tracks }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
presetSection
|
||||
pagesSection
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preset Section
|
||||
|
||||
private var presetSection: some View {
|
||||
Section {
|
||||
LabeledContent(
|
||||
String(localized: "settings.playerControls.activePreset"),
|
||||
value: viewModel.activePreset?.name ?? ""
|
||||
)
|
||||
|
||||
if !viewModel.canEditActivePreset {
|
||||
Button {
|
||||
Task { await duplicateActivePreset() }
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "panel.settings.duplicatePreset"),
|
||||
systemImage: "doc.on.doc"
|
||||
)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func duplicateActivePreset() async {
|
||||
guard let activePreset = viewModel.activePreset else { return }
|
||||
|
||||
// Pick a unique name for the copy.
|
||||
let existingNames = Set(viewModel.presets.map(\.name))
|
||||
let base = "\(activePreset.name) Copy"
|
||||
var name = base
|
||||
var counter = 2
|
||||
while existingNames.contains(name) {
|
||||
name = "\(base) \(counter)"
|
||||
counter += 1
|
||||
}
|
||||
|
||||
await viewModel.createPreset(name: name, basedOn: activePreset)
|
||||
}
|
||||
|
||||
// MARK: - Pages Section
|
||||
|
||||
private var pagesSection: some View {
|
||||
Section {
|
||||
ForEach(tvOSPages) { page in
|
||||
NavigationLink {
|
||||
TVPanelPageEditorView(pageType: page.pageType, viewModel: viewModel)
|
||||
} label: {
|
||||
HStack {
|
||||
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
|
||||
Spacer()
|
||||
Text(page.isEnabled
|
||||
? String(localized: "common.on")
|
||||
: String(localized: "common.off"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "panel.settings.pages.header"))
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.pages.footer"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page Editor
|
||||
|
||||
private struct TVPanelPageEditorView: View {
|
||||
let pageType: PanelPageType
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var isEnabled: Bool = true
|
||||
@State private var showDescription: Bool = true
|
||||
@State private var showStatsRow: Bool = true
|
||||
@State private var showChannelRow: Bool = true
|
||||
|
||||
private var configuration: PanelPageConfiguration? {
|
||||
viewModel.panelSettings.configuration(for: pageType)
|
||||
}
|
||||
|
||||
/// Whether this is the only enabled page; the last one cannot be disabled.
|
||||
private var isLastEnabledPage: Bool {
|
||||
isEnabled && viewModel.enabledPanelPageCount <= 1
|
||||
}
|
||||
|
||||
/// Pages as displayed in the tvOS list (tracks hidden).
|
||||
private var displayedPages: [PanelPageConfiguration] {
|
||||
viewModel.panelPages.filter { $0.pageType != .tracks }
|
||||
}
|
||||
|
||||
private var displayedIndex: Int? {
|
||||
displayedPages.firstIndex { $0.pageType == pageType }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.page.enabled"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.disabled(!viewModel.canEditActivePreset || isLastEnabledPage)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
guard newValue != configuration?.isEnabled else { return }
|
||||
viewModel.setPanelPageEnabled(pageType, isEnabled: newValue)
|
||||
}
|
||||
|
||||
Button {
|
||||
move(up: true)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveUp"), systemImage: "arrow.up")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || displayedIndex == 0)
|
||||
|
||||
Button {
|
||||
move(up: false)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1)
|
||||
} footer: {
|
||||
if isLastEnabledPage {
|
||||
Text(String(localized: "panel.settings.page.lastEnabled.footer"))
|
||||
} else if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
}
|
||||
}
|
||||
|
||||
if pageType == .info {
|
||||
infoElementsSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(pageType.displayName)
|
||||
.onAppear {
|
||||
syncLocalState()
|
||||
}
|
||||
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
||||
syncLocalState()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reordering
|
||||
|
||||
/// Moves this page past its displayed neighbor. Indices are mapped back
|
||||
/// to the full pages list, which may contain pages the tvOS UI hides.
|
||||
private func move(up: Bool) {
|
||||
let displayed = displayedPages
|
||||
guard let index = displayed.firstIndex(where: { $0.pageType == pageType }) else { return }
|
||||
let neighborIndex = up ? index - 1 : index + 1
|
||||
guard displayed.indices.contains(neighborIndex) else { return }
|
||||
|
||||
let allPages = viewModel.panelPages
|
||||
guard let source = allPages.firstIndex(where: { $0.pageType == pageType }),
|
||||
let target = allPages.firstIndex(where: { $0.pageType == displayed[neighborIndex].pageType })
|
||||
else { return }
|
||||
|
||||
viewModel.movePanelPages(
|
||||
fromOffsets: IndexSet(integer: source),
|
||||
toOffset: up ? target : target + 1
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Info Elements Section
|
||||
|
||||
private var infoElementsSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.info.showStatsRow"),
|
||||
isOn: $showStatsRow
|
||||
)
|
||||
.onChange(of: showStatsRow) { _, newValue in
|
||||
guard newValue != infoSettings.effectiveShowStatsRow else { return }
|
||||
viewModel.updatePanelInfoSettingsSync { $0.showStatsRow = newValue }
|
||||
}
|
||||
|
||||
Toggle(
|
||||
String(localized: "panel.settings.info.showChannelRow"),
|
||||
isOn: $showChannelRow
|
||||
)
|
||||
.onChange(of: showChannelRow) { _, newValue in
|
||||
guard newValue != infoSettings.effectiveShowChannelRow else { return }
|
||||
viewModel.updatePanelInfoSettingsSync { $0.showChannelRow = newValue }
|
||||
}
|
||||
|
||||
Toggle(
|
||||
String(localized: "panel.settings.info.showDescription"),
|
||||
isOn: $showDescription
|
||||
)
|
||||
.onChange(of: showDescription) { _, newValue in
|
||||
guard newValue != infoSettings.effectiveShowDescription else { return }
|
||||
viewModel.updatePanelInfoSettingsSync { $0.showDescription = newValue }
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "panel.settings.elements.header"))
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var infoSettings: InfoPageSettings {
|
||||
configuration?.infoSettings ?? InfoPageSettings()
|
||||
}
|
||||
|
||||
private func syncLocalState() {
|
||||
isEnabled = configuration?.isEnabled ?? true
|
||||
let info = infoSettings
|
||||
showDescription = info.effectiveShowDescription
|
||||
showStatsRow = info.effectiveShowStatsRow
|
||||
showChannelRow = info.effectiveShowChannelRow
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -195,6 +195,10 @@ struct SettingsView: View {
|
||||
Label(String(localized: "settings.playback.sectionTitle"), systemImage: "play.circle")
|
||||
}
|
||||
|
||||
NavigationLink { TVSidebarDetailContainer(systemImage: "list.bullet.rectangle", title: String(localized: "settings.playerPanels.title")) { TVPlayerPanelsSettingsView() } } label: {
|
||||
Label(String(localized: "settings.playerPanels.title"), systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
|
||||
NavigationLink { TVSidebarDetailContainer(systemImage: "hand.raised", title: String(localized: "settings.privacy.title")) { PrivacySettingsView() } } label: {
|
||||
Label(String(localized: "settings.privacy.title"), systemImage: "hand.raised")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user