import Defaults import SDWebImageSwiftUI import SwiftUI struct ControlsBar: View { enum ExpansionState { case mini case full } @Binding var fullScreen: Bool @State private var presentingShareSheet = false @State private var shareURL: URL? @Binding var expansionState: ExpansionState @State internal var gestureThrottle = Throttle(interval: 0.25) var presentingControls = true var backgroundEnabled = true var detailsTogglePlayer = true var detailsToggleFullScreen = false var playerBar = false var titleLineLimit = 2 @ObservedObject private var accounts = AccountsModel.shared @ObservedObject private var model = PlayerModel.shared @ObservedObject private var playlists = PlaylistsModel.shared @ObservedObject private var subscriptions = SubscribedChannelsModel.shared @ObservedObject private var controls = PlayerControlsModel.shared @Environment(\.navigationStyle) private var navigationStyle private let navigation = NavigationModel.shared private let controlsOverlayModel = ControlOverlaysModel.shared @Default(.playerButtonShowsControlButtonsWhenMinimized) private var controlsWhenMinimized @Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture @Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture var body: some View { HStack(spacing: 0) { detailsButton if presentingControls, expansionState == .full || (controlsWhenMinimized && model.currentItem != nil) { if expansionState == .full { Spacer() } controlsView .frame(maxWidth: 120) } } .buttonStyle(.plain) .labelStyle(.iconOnly) .padding(.horizontal, 10) .padding(.vertical, 2) .frame(maxHeight: barHeight) .padding(.trailing, expansionState == .mini && !controlsWhenMinimized ? 8 : 0) .modifier(ControlBackgroundModifier(enabled: backgroundEnabled)) .clipShape(RoundedRectangle(cornerRadius: expansionState == .full || !playerBar ? 0 : 6)) .overlay( RoundedRectangle(cornerRadius: expansionState == .full || !playerBar ? 0 : 6) .stroke(Color("ControlsBorderColor"), lineWidth: 0.5) ) #if os(iOS) .background( EmptyView().sheet(isPresented: $presentingShareSheet) { if let shareURL { ShareSheet(activityItems: [shareURL]) } } ) #endif } @ViewBuilder var detailsButton: some View { if detailsTogglePlayer { Button { model.togglePlayer() } label: { details .contentShape(Rectangle()) } } else if detailsToggleFullScreen { Button { controlsOverlayModel.hide() controls.presentingControls = false withAnimation { fullScreen.toggle() } } label: { details .contentShape(Rectangle()) } #if !os(tvOS) .keyboardShortcut("t") #endif } else { details } } var controlsView: some View { HStack(spacing: 4) { Group { if controls.isPlaying { Button(action: { model.pause() }) { Label("Pause", systemImage: "pause.fill") .padding(.vertical, 10) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } } else { Button(action: { model.play() }) { Label("Play", systemImage: "play.fill") .padding(.vertical, 10) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } } } .disabled(controls.isLoadingVideo || model.currentItem.isNil) Button(action: { model.advanceToNextItem() }) { Label("Next", systemImage: "forward.fill") .padding(.vertical, 10) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .disabled(!model.isAdvanceToNextItemAvailable) Button { model.closeCurrentItem() } label: { Label("Close Video", systemImage: "xmark") .padding(.vertical, 10) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .disabled(model.currentItem.isNil) } .imageScale(.small) .font(.system(size: 24)) } var barHeight: Double { 55 } var details: some View { HStack { HStack(spacing: 8) { if !playerBar { Button { if let video = model.videoForDisplay, !video.isLocal { navigation.openChannel( video.channel, navigationStyle: navigationStyle ) } } label: { ChannelAvatarView( channel: model.videoForDisplay?.channel, video: model.videoForDisplay ) .id("channel-avatar-\(model.videoForDisplay?.id ?? "")") .frame(width: barHeight - 10, height: barHeight - 10) } .contextMenu { contextMenu } .zIndex(3) } else { ChannelAvatarView( channel: model.videoForDisplay?.channel, video: model.videoForDisplay ) .id("channel-avatar-\(model.videoForDisplay?.id ?? "")") #if !os(tvOS) .highPriorityGesture(playerButtonDoubleTapGesture != .nothing ? doubleTapGesture : nil) .gesture(playerButtonSingleTapGesture != .nothing ? singleTapGesture : nil) #endif .frame(width: barHeight - 10, height: barHeight - 10) .contextMenu { contextMenu } } if expansionState == .full { VStack(alignment: .leading, spacing: 0) { let notPlaying = "Not Playing".localized() Text(model.videoForDisplay?.displayTitle ?? notPlaying) .font(.system(size: 14)) .fontWeight(.semibold) .foregroundColor(model.videoForDisplay.isNil ? .secondary : .accentColor) .fixedSize(horizontal: false, vertical: true) .lineLimit(titleLineLimit) .multilineTextAlignment(.leading) if let video = model.videoForDisplay, !video.localStreamIsFile { HStack(spacing: 2) { Text(video.displayAuthor) .font(.system(size: 12)) if !presentingControls && !video.isLocal { HStack(spacing: 2) { Image(systemName: "person.2.fill") if let channel = model.videoForDisplay?.channel { if let subscriptions = channel.subscriptionsString { Text(subscriptions) } else { Text("1234").redacted(reason: .placeholder) } } } .padding(.leading, 4) .font(.system(size: 9)) } } .lineLimit(1) .foregroundColor(.secondary) } } .zIndex(0) .transition(.opacity) if !playerBar { Spacer() } } } .buttonStyle(.plain) .padding(.vertical) } } #if !os(tvOS) var singleTapGesture: some Gesture { TapGesture(count: 1).onEnded { gestureAction(playerButtonSingleTapGesture) } } var doubleTapGesture: some Gesture { TapGesture(count: 2).onEnded { gestureAction(playerButtonDoubleTapGesture) } } func gestureAction(_ action: PlayerTapGestureAction) { gestureThrottle.execute { switch action { case .togglePlayer: self.model.togglePlayer() case .openChannel: guard let channel = self.model.videoForDisplay?.channel else { return } self.navigation.openChannel(channel, navigationStyle: self.navigationStyle) case .togglePlayerVisibility: withAnimation(.spring(response: 0.25)) { self.expansionState = self.expansionState == .full ? .mini : .full } default: return } } } #endif @ViewBuilder var contextMenu: some View { if let video = model.videoForDisplay { Group { Section { if accounts.app.supportsUserPlaylists && accounts.signedIn, !video.isLocal { Section { Button { navigation.presentAddToPlaylist(video) } label: { Label("Add to Playlist...", systemImage: "text.badge.plus") } if let playlist = playlists.lastUsed, let video = model.videoForDisplay { Button { playlists.addVideo(playlistID: playlist.id, videoID: video.videoID) } label: { Label("Add to \(playlist.title)", systemImage: "text.badge.star") } } } } #if !os(tvOS) ShareButton(contentItem: .init(video: model.videoForDisplay)) #endif Section { if !video.isLocal { Button { navigation.openChannel( video.channel, navigationStyle: navigationStyle ) } label: { Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop") } if accounts.app.supportsSubscriptions, accounts.signedIn { if subscriptions.isSubscribing(video.channel.id) { Button { #if os(tvOS) subscriptions.unsubscribe(video.channel.id) #else navigation.presentUnsubscribeAlert(video.channel, subscriptions: subscriptions) #endif } label: { Label("Unsubscribe", systemImage: "star.circle") } } else { Button { subscriptions.subscribe(video.channel.id) { navigation.sidebarSectionChanged.toggle() } } label: { Label("Subscribe", systemImage: "star.circle") } } } } } } Button { model.closeCurrentItem() } label: { Label("Close Video", systemImage: "xmark") } } .labelStyle(.automatic) } } } struct ControlsBar_Previews: PreviewProvider { static var previews: some View { ControlsBar(fullScreen: .constant(false), expansionState: .constant(.full)) .injectFixtureEnvironmentObjects() } }