mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Add a Custom swatch to the appearance settings accent color grid, backed by a hex value stored in settings (synced via iCloud like the rest). iOS uses the native ColorPicker wheel; macOS uses a circular swatch that opens NSColorPanel, since the SwiftUI color well looks out of place among the circles. All accent consumers now read the resolved color through SettingsManager.resolvedAccentColor. The indigo preset is retired from the grid but kept in the enum, so users who selected it keep their color until they pick another one. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
575 lines
24 KiB
Swift
575 lines
24 KiB
Swift
//
|
|
// PortraitDetailsPanel.swift
|
|
// Yattee
|
|
//
|
|
// Details panel for portrait mode with no header bar.
|
|
// Shows video info, description, comments pill, and queue pill.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
|
|
struct PortraitDetailsPanel: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let onChannelTap: (() -> Void)?
|
|
let playerControlsLayout: PlayerControlsLayout
|
|
let onFullscreen: (() -> Void)?
|
|
let pillsOverlayOpacity: CGFloat
|
|
|
|
// Drag gesture callbacks
|
|
var onDragChanged: ((CGFloat) -> Void)?
|
|
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
|
|
var onDragCancelled: (() -> Void)?
|
|
@GestureState private var isDraggingHandle: Bool = false
|
|
@State private var isCommentsExpanded: Bool = false
|
|
@State private var showFormattedDate = false
|
|
@State private var scrollOffset: CGFloat = 0
|
|
@State private var scrollToTopTrigger: Bool = false
|
|
@State private var showQueueSheet: Bool = false
|
|
@State private var showPlaylistSheet: Bool = false
|
|
|
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
|
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
|
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
|
private var playerState: PlayerState? { playerService?.state }
|
|
private var isPanelDragging: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false }
|
|
|
|
// Read video and dislikeCount from playerState for reactive updates
|
|
private var video: Video? { playerState?.currentVideo }
|
|
private var dislikeCount: Int? { playerState?.dislikeCount }
|
|
|
|
// Comments helpers
|
|
private var comments: [Comment] { playerState?.comments ?? [] }
|
|
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
|
|
|
|
// Video details helpers
|
|
private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .idle }
|
|
|
|
// Queue helpers
|
|
private var queue: [QueuedVideo] { playerState?.queue ?? [] }
|
|
private var history: [QueuedVideo] { playerState?.history ?? [] }
|
|
private var isQueueEnabled: Bool { settingsManager?.queueEnabled ?? true }
|
|
|
|
// Player pill helpers
|
|
private var playerPillSettings: PlayerPillSettings {
|
|
playerControlsLayout.effectivePlayerPillSettings
|
|
}
|
|
private var shouldShowPlayerPill: Bool {
|
|
playerPillSettings.visibility.isVisible(isWideLayout: false) &&
|
|
!playerPillSettings.buttons.isEmpty
|
|
}
|
|
|
|
// Comments pill helpers
|
|
private var commentsPillMode: CommentsPillMode {
|
|
playerPillSettings.effectiveCommentsPillMode
|
|
}
|
|
private var shouldShowCommentsPill: Bool {
|
|
playerPillSettings.shouldShowCommentsPill
|
|
}
|
|
private var isCommentsPillAlwaysCollapsed: Bool {
|
|
playerPillSettings.isCommentsPillAlwaysCollapsed
|
|
}
|
|
|
|
private var hasVideoDescription: Bool {
|
|
!(video?.description ?? "").isEmpty
|
|
}
|
|
|
|
/// Whether any pills are currently visible in the overlay
|
|
private var hasPillsVisible: Bool {
|
|
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
|
|
return shouldShowPlayerPill || hasCommentsPill
|
|
}
|
|
|
|
/// Whether comments pill is shown expanded on its own row (above player pill)
|
|
private var hasExpandedCommentsPill: Bool {
|
|
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
|
|
return hasCommentsPill && shouldShowPlayerPill && !isCommentsPillAlwaysCollapsed && hasVideoDescription
|
|
}
|
|
|
|
/// Returns the first enabled Yattee Server instance URL, if any.
|
|
private var yatteeServerURL: URL? {
|
|
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
|
|
}
|
|
|
|
/// Returns the DeArrow title if available and enabled, otherwise the original title.
|
|
private func displayTitle(for video: Video) -> String {
|
|
appEnvironment?.deArrowBrandingProvider.title(for: video) ?? video.title
|
|
}
|
|
|
|
// MARK: - Drag Handle
|
|
|
|
private var dragHandle: some View {
|
|
Capsule()
|
|
.fill(Color.secondary.opacity(0.4))
|
|
.frame(width: 36, height: 4)
|
|
.padding(.top, 5)
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
// MARK: - Bottom Fade Overlay
|
|
|
|
@ViewBuilder
|
|
private var bottomFadeOverlay: some View {
|
|
// Fade height: tall when comments expanded on own row, medium for single-row pills, short when no pills
|
|
// When expanded: 24pt padding + 52pt player pill + 12pt spacing + ~26pt (half comments pill) ≈ 115pt
|
|
let fadeHeight: CGFloat = hasExpandedCommentsPill ? 115 : (hasPillsVisible ? 70 : 25)
|
|
|
|
ZStack {
|
|
// Touch-blocking layer (invisible but intercepts touches)
|
|
Color.white.opacity(0.001)
|
|
.frame(height: fadeHeight)
|
|
.contentShape(Rectangle())
|
|
|
|
// Visual gradient
|
|
LinearGradient(
|
|
colors: [.clear, Color(.systemBackground)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: fadeHeight)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.frame(height: fadeHeight)
|
|
.animation(.easeInOut(duration: 0.2), value: hasExpandedCommentsPill)
|
|
.animation(.easeInOut(duration: 0.2), value: hasPillsVisible)
|
|
}
|
|
|
|
var body: some View {
|
|
if let video {
|
|
ZStack(alignment: .top) {
|
|
// Scrollable content (extends to top, scrolls under drag handle)
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Anchor for scroll-to-top
|
|
Color.clear
|
|
.frame(height: 0)
|
|
.id("panelTop")
|
|
|
|
// Top padding so content isn't initially obscured by drag handle
|
|
Color.clear
|
|
.frame(height: 10)
|
|
|
|
videoInfo(video)
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
infoTabSection(video)
|
|
}
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.background {
|
|
// UIKit gesture handler for smooth overscroll-to-collapse
|
|
OverscrollGestureView(
|
|
isEnabled: !isDraggingHandle,
|
|
onDragChanged: { offset in
|
|
onDragChanged?(offset)
|
|
},
|
|
onDragEnded: { offset, predicted in
|
|
onDragEnded?(offset, predicted)
|
|
}
|
|
)
|
|
}
|
|
.scrollDisabled(isDraggingHandle)
|
|
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
|
geometry.contentOffset.y
|
|
} action: { _, newValue in
|
|
// Update scrollOffset for pills visibility
|
|
scrollOffset = max(0, newValue)
|
|
}
|
|
.onChange(of: scrollToTopTrigger) { _, _ in
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
proxy.scrollTo("panelTop", anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
bottomFadeOverlay
|
|
}
|
|
|
|
// Extended drag hit area overlay - visual handle at top, hit area extends down
|
|
VStack(spacing: 0) {
|
|
dragHandle
|
|
Spacer()
|
|
}
|
|
.frame(height: 50)
|
|
.frame(maxWidth: .infinity)
|
|
.background(alignment: .top) {
|
|
LinearGradient(
|
|
colors: [Color(.systemBackground), Color(.systemBackground).opacity(0)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 25)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.highPriorityGesture(
|
|
DragGesture(coordinateSpace: .global)
|
|
.updating($isDraggingHandle) { _, state, _ in
|
|
state = true
|
|
}
|
|
.onChanged { value in
|
|
// Allow both upward (negative) and downward (positive) drag
|
|
let offset = value.translation.height
|
|
onDragChanged?(offset)
|
|
}
|
|
.onEnded { value in
|
|
onDragEnded?(value.translation.height, value.predictedEndTranslation.height)
|
|
}
|
|
)
|
|
.onChange(of: isDraggingHandle) { oldValue, newValue in
|
|
// When gesture state resets (true -> false) but onEnded wasn't called,
|
|
// the gesture was cancelled (app backgrounded, control center, etc.)
|
|
if oldValue && !newValue {
|
|
// Small delay to let onEnded fire first if it will
|
|
DispatchQueue.main.async {
|
|
// If still dragging according to parent, it was a cancellation
|
|
if appEnvironment?.navigationCoordinator.isPanelDragging == true {
|
|
onDragCancelled?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
// Pills overlay at bottom
|
|
.overlay {
|
|
if !isCommentsExpanded {
|
|
pillsOverlay
|
|
.opacity(pillsOverlayOpacity)
|
|
.allowsHitTesting(pillsOverlayOpacity > 0.01)
|
|
}
|
|
}
|
|
// Expanded comments overlay
|
|
.overlay {
|
|
let expanded = isCommentsExpanded
|
|
ExpandedCommentsView(
|
|
videoID: video.id.videoID,
|
|
onClose: collapseComments,
|
|
onDismissGestureEnded: handleCommentsDismissGestureEnded,
|
|
onDragChanged: onDragChanged,
|
|
onDragEnded: onDragEnded
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
.visualEffect { content, proxy in
|
|
content.offset(y: expanded ? 0 : proxy.size.height)
|
|
}
|
|
.opacity(expanded ? 1 : 0)
|
|
.allowsHitTesting(expanded)
|
|
.background(
|
|
GeometryReader { commentsGeometry in
|
|
Color.clear
|
|
.onChange(of: commentsGeometry.frame(in: .global)) { _, newFrame in
|
|
if isCommentsExpanded {
|
|
appEnvironment?.navigationCoordinator.commentsFrame = newFrame
|
|
}
|
|
}
|
|
.onChange(of: isCommentsExpanded) { _, expanded in
|
|
if expanded {
|
|
appEnvironment?.navigationCoordinator.commentsFrame = commentsGeometry.frame(in: .global)
|
|
} else {
|
|
appEnvironment?.navigationCoordinator.commentsFrame = .zero
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.animation(.smooth(duration: 0.3), value: isCommentsExpanded)
|
|
.onChange(of: video.id) { _, _ in
|
|
isCommentsExpanded = false
|
|
}
|
|
.onChange(of: isCommentsExpanded) { _, newValue in
|
|
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
|
|
}
|
|
.sheet(isPresented: $showQueueSheet) {
|
|
QueueManagementSheet()
|
|
}
|
|
.sheet(isPresented: $showPlaylistSheet) {
|
|
if let currentVideo = playerState?.currentVideo {
|
|
PlaylistSelectorSheet(video: currentVideo)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pills Overlay
|
|
|
|
@ViewBuilder
|
|
private var pillsOverlay: some View {
|
|
GeometryReader { geometry in
|
|
let isScrolled = scrollOffset > 20
|
|
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
|
|
let isPlaying = playerState?.playbackState == .playing
|
|
let hasNext = playerState?.hasNext ?? false
|
|
// On narrow devices, use smaller side buttons so the player pill gets more horizontal space
|
|
let isCompactPillRow = geometry.size.width <= 390
|
|
let collapsedCommentWidth: CGFloat = isCompactPillRow ? 40 : 52
|
|
let scrollButtonWidth: CGFloat = isCompactPillRow ? 40 : 52
|
|
let pillRowSpacing: CGFloat = isCompactPillRow ? 8 : 12
|
|
|
|
VStack {
|
|
Spacer()
|
|
|
|
VStack(spacing: 12) {
|
|
// Comments pill - on its own row (only when player pill exists AND not collapsed)
|
|
let isPillCollapsed = isScrolled || isCommentsPillAlwaysCollapsed || !hasVideoDescription
|
|
if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill, !isPillCollapsed {
|
|
CommentsPillView(
|
|
comment: firstComment,
|
|
isCollapsed: false,
|
|
onTap: expandComments
|
|
)
|
|
}
|
|
|
|
// Bottom row
|
|
bottomPillsRow(
|
|
hasCommentsPill: hasCommentsPill,
|
|
collapsedCommentWidth: collapsedCommentWidth,
|
|
pillRowSpacing: pillRowSpacing,
|
|
isCompact: isCompactPillRow,
|
|
isPlaying: isPlaying,
|
|
hasNext: hasNext,
|
|
scrollButtonWidth: scrollButtonWidth,
|
|
availableWidth: geometry.size.width,
|
|
isScrolled: isScrolled
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 24)
|
|
}
|
|
// Only animate when comments load or player pill changes, not on panel size changes
|
|
// Suppress animations during panel drag to prevent independent animation on release
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill)
|
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
|
|
.animation(isPanelDragging ? nil : .easeInOut(duration: 0.2), value: isScrolled)
|
|
}
|
|
}
|
|
|
|
/// Bottom row of pills (comments, player pill, scroll button) - extracted to help compiler
|
|
@ViewBuilder
|
|
private func bottomPillsRow(
|
|
hasCommentsPill: Bool,
|
|
collapsedCommentWidth: CGFloat,
|
|
pillRowSpacing: CGFloat,
|
|
isCompact: Bool,
|
|
isPlaying: Bool,
|
|
hasNext: Bool,
|
|
scrollButtonWidth: CGFloat,
|
|
availableWidth: CGFloat,
|
|
isScrolled: Bool
|
|
) -> some View {
|
|
// Calculate max pill width: available width minus placeholders and spacing
|
|
// Layout: [16 padding] [left] [spacing] [PILL] [spacing] [right] [16 padding]
|
|
let edgeElementsWidth: CGFloat = 32 + collapsedCommentWidth + scrollButtonWidth + (pillRowSpacing * 2)
|
|
let maxPillWidth = max(availableWidth - edgeElementsWidth, 150)
|
|
|
|
HStack(spacing: pillRowSpacing) {
|
|
// Left side: comments pill or placeholder
|
|
let isPillCollapsed = isScrolled || isCommentsPillAlwaysCollapsed || !hasVideoDescription
|
|
if hasCommentsPill, let firstComment = comments.first {
|
|
if !shouldShowPlayerPill {
|
|
// No player pill: show comments in bottom row (respect collapse mode)
|
|
CommentsPillView(
|
|
comment: firstComment,
|
|
isCollapsed: isPillCollapsed,
|
|
fillWidth: !isPillCollapsed,
|
|
compact: isCompact && isPillCollapsed,
|
|
onTap: expandComments
|
|
)
|
|
.frame(maxWidth: isPillCollapsed ? nil : 400)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else if isPillCollapsed {
|
|
// Player pill exists AND collapsed: show comments button on left
|
|
CommentsPillView(
|
|
comment: firstComment,
|
|
isCollapsed: true,
|
|
compact: isCompact,
|
|
onTap: expandComments
|
|
)
|
|
} else {
|
|
// Placeholder (expanded comments on its own row)
|
|
Color.clear.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
|
|
}
|
|
} else if shouldShowPlayerPill {
|
|
Color.clear.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
|
|
}
|
|
|
|
// Player pill
|
|
if shouldShowPlayerPill {
|
|
PlayerPillView(
|
|
settings: playerPillSettings,
|
|
maxWidth: maxPillWidth,
|
|
isWideLayout: false,
|
|
isPlaying: isPlaying,
|
|
hasNext: hasNext,
|
|
queueCount: queue.count,
|
|
queueModeIcon: playerState?.queueMode.icon ?? "list.bullet",
|
|
isPlayPauseDisabled: playerState?.playbackState == .loading,
|
|
isOrientationLocked: appEnvironment?.settingsManager.inAppOrientationLock ?? false,
|
|
video: video,
|
|
playbackRate: playerState?.rate ?? .x1,
|
|
showingPlaylistSheet: $showPlaylistSheet,
|
|
onQueueTap: { showQueueSheet = true },
|
|
onPrevious: { Task { await playerService?.playPrevious() } },
|
|
onPlayPause: { playerService?.togglePlayPause() },
|
|
onNext: { Task { await playerService?.playNext() } },
|
|
onSeek: { signedSeconds in
|
|
if signedSeconds >= 0 {
|
|
playerService?.seekForward(by: signedSeconds)
|
|
} else {
|
|
playerService?.seekBackward(by: -signedSeconds)
|
|
}
|
|
},
|
|
onClose: {
|
|
appEnvironment?.queueManager.clearQueue()
|
|
playerService?.stop()
|
|
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
|
},
|
|
onAirPlay: { /* AirPlay - handled by system */ },
|
|
onPiP: {
|
|
#if os(iOS)
|
|
if let mpvBackend = playerService?.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
#endif
|
|
},
|
|
onOrientationLock: {
|
|
#if os(iOS)
|
|
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
|
|
#endif
|
|
},
|
|
onFullscreen: { onFullscreen?() },
|
|
onRateChanged: { rate in
|
|
playerState?.rate = rate
|
|
playerService?.currentBackend?.rate = Float(rate.rawValue)
|
|
}
|
|
)
|
|
}
|
|
|
|
// Spacer to push scroll button trailing when no player pill
|
|
if !shouldShowPlayerPill {
|
|
Spacer()
|
|
}
|
|
|
|
// Scroll button or placeholder
|
|
if scrollOffset > 20 {
|
|
Button {
|
|
scrollToTopTrigger.toggle()
|
|
} label: {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: isCompact ? 16 : 18, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: isCompact ? 28 : 32, height: isCompact ? 28 : 32)
|
|
.padding(isCompact ? 6 : 10)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.glassBackground(.regular, in: .circle, fallback: .thinMaterial)
|
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
|
.contentShape(Circle())
|
|
.transition(.scale.combined(with: .opacity))
|
|
} else if shouldShowPlayerPill {
|
|
Color.clear.frame(width: scrollButtonWidth, height: scrollButtonWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Info
|
|
|
|
private func videoInfo(_ video: Video) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Title - full width (shows DeArrow title if available)
|
|
Text(displayTitle(for: video))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(3)
|
|
|
|
// Stats row - only show for non-media-source videos
|
|
if !video.isFromMediaSource {
|
|
VideoStatsRow(
|
|
playerState: playerState,
|
|
showFormattedDate: $showFormattedDate,
|
|
returnYouTubeDislikeEnabled: settingsManager?.returnYouTubeDislikeEnabled ?? false
|
|
)
|
|
}
|
|
|
|
// Channel row with context menu
|
|
VideoChannelRow(
|
|
author: video.author,
|
|
source: video.id.source,
|
|
yatteeServerURL: yatteeServerURL,
|
|
onChannelTap: onChannelTap,
|
|
video: video,
|
|
accentColor: accentColor,
|
|
showSubscriberCount: !video.isFromMediaSource,
|
|
isLoadingDetails: playerState?.videoDetailsState == .loading
|
|
)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Info Tab Section
|
|
|
|
private func infoTabSection(_ video: Video) -> some View {
|
|
let hasDescription = !(video.description ?? "").isEmpty
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
descriptionContent(video.description ?? "")
|
|
|
|
// Reduced bottom spacer when no description
|
|
Spacer()
|
|
.frame(height: hasDescription ? 80 : 50)
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
|
|
private func expandComments() {
|
|
withAnimation(.smooth(duration: 0.3)) {
|
|
isCommentsExpanded = true
|
|
}
|
|
}
|
|
|
|
private func collapseComments() {
|
|
appEnvironment?.navigationCoordinator.commentsFrame = .zero
|
|
withAnimation(.smooth(duration: 0.3)) {
|
|
isCommentsExpanded = false
|
|
}
|
|
}
|
|
|
|
private func handleCommentsDismissGestureEnded(_ finalOffset: CGFloat) {
|
|
let dismissThreshold: CGFloat = 30
|
|
if finalOffset >= dismissThreshold {
|
|
collapseComments()
|
|
}
|
|
}
|
|
|
|
private func descriptionContent(_ description: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if !description.isEmpty {
|
|
Text(DescriptionText.attributed(description, linkColor: accentColor))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.tint(accentColor)
|
|
.padding(.horizontal)
|
|
.handleTimestampLinks(using: playerService)
|
|
} else if videoDetailsState == .loading {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
// No description text removed - let panel shrink instead
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|