diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index 816dec7..f71502f 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -259,6 +259,7 @@ public extension ContentCore { } .frame(maxWidth: .infinity) .padding(.horizontal) + .padding(.bottom) } .onAppear { proxy.scrollTo(selectedItemId, anchor: .center) diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift index 9b54d56..23ffa7a 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift @@ -46,7 +46,7 @@ extension ModuleListsFeature.View: View { await viewStore.send(.onTask).finish() } } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 300) } } diff --git a/Sources/Features/VideoPlayer/Components/ProgressBar.swift b/Sources/Features/VideoPlayer/Components/ProgressBar.swift index 253c62a..ec40667 100644 --- a/Sources/Features/VideoPlayer/Components/ProgressBar.swift +++ b/Sources/Features/VideoPlayer/Components/ProgressBar.swift @@ -19,7 +19,9 @@ struct ProgressBar: View { private var viewState: ViewStore @SwiftUI.State - private var dragProgress: Double? + private var dragProgress: Double = 0 + @SwiftUI.State + private var isDragging = false @Dependency(\.dateComponentsFormatter) var formatter @@ -39,7 +41,7 @@ struct ProgressBar: View { .preferredColorScheme(.dark) Color.white .frame( - width: proxy.size.width * (isDragging ? dragProgress ?? progress : progress), + width: proxy.size.width * dragProgress, height: proxy.size.height, alignment: .leading ) @@ -60,18 +62,13 @@ struct ProgressBar: View { .onChanged { value in if !isDragging { dragProgress = progress + isDragging = true } - - let locationX = value.location.x - let percentage = locationX / proxy.size.width - - dragProgress = max(0, min(1.0, percentage)) + dragProgress = max(0, min(1.0, value.location.x / proxy.size.width)) + viewState.send(.didSkipTo(time: dragProgress)) } .onEnded { _ in - if let dragProgress { - viewState.send(.didSkipTo(time: dragProgress)) - } - dragProgress = nil + isDragging = false } ) .animation(.spring(response: 0.3), value: isDragging) @@ -94,12 +91,17 @@ struct ProgressBar: View { .font(.caption.monospacedDigit()) } .disabled(!canUseControls) + .onAppear { + dragProgress = viewState.state?.progress ?? 0 + } + // Stop initial bounce + .animation(.linear(duration: 0), value: dragProgress) } private var progressDisplayTime: String { if canUseControls { if isDragging { - let time = (dragProgress ?? .zero) * (viewState.state?.totalDuration ?? .zero) + let time = dragProgress * (viewState.state?.totalDuration ?? .zero) return formatter.playbackTimestamp(time) ?? Self.defaultZeroTime } else { return formatter.playbackTimestamp(viewState.state?.duration ?? .zero) ?? Self.defaultZeroTime @@ -122,10 +124,6 @@ private extension ProgressBar { } } - var isDragging: Bool { - dragProgress != nil - } - var canUseControls: Bool { if let totalDuration = viewState.state?.totalDuration { return !totalDuration.isNaN && !totalDuration.isInfinite && !totalDuration.isZero diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index 18beb56..4539897 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -72,9 +72,12 @@ extension VideoPlayerFeature: Reducer { return state.clearForChangedLinkIfNeeded(linkId) case let .view(.didSkipTo(time)): - return .run { _ in - await playerClient.seek(time) - } + return .merge( + state.delayDismissOverlayIfNeeded(), + .run { _ in + await playerClient.seek(time) + } + ) case .view(.didTogglePlayback): let isPlaying = state.player.playback?.state == .playing @@ -95,7 +98,7 @@ extension VideoPlayerFeature: Reducer { await playerClient.setRate(.init(rate)) } - case .view(.didSkipFowards): + case .view(.didSkipForward): let skipTime = state.playerSettings.skipTime // In seconds let currentProgress = state.player.playback?.progress ?? .zero let totalDuration = state.player.playback?.totalDuration ?? 1 diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift index 3ab1117..ce8f711 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift @@ -22,46 +22,57 @@ extension VideoPlayerFeature.View: View { @MainActor public var body: some View { ZStack { - WithViewStore(store, observe: \.overlay == nil) { viewStore in - PlayerView( - player: player(), - gravity: gravity, - enablePIP: $enablePiP - ) - // Reducer should not handle these properties, they should be binded to the view instead. - .pictureInPictureIsPossible { possible in - pipPossible = possible - } - .pictureInPictureIsSupported { supported in - pipSupported = supported - } - .pictureInPictureStatus { status in - pipStatus = status - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .edgesIgnoringSafeArea(.all) - .ignoresSafeArea(.all, edges: .all) - .contentShape(Rectangle()) - .gesture( - MagnificationGesture() - .onEnded { _ in - if gravity == .resizeAspect { - gravity = .resizeAspectFill - } else { - gravity = .resizeAspect - } + GeometryReader { proxy in + WithViewStore(store, observe: \.overlay == nil) { viewStore in + WithViewStore(store, observe: RateBufferingState.init) { rateBufferingState in + PlayerView( + player: player(), + gravity: gravity, + enablePIP: $enablePiP + ) + // Reducer should not handle these properties, they should be binded to the view instead. + .pictureInPictureIsPossible { possible in + pipPossible = possible + } + .pictureInPictureIsSupported { supported in + pipSupported = supported } - ) - .gesture( - TapGesture() - .onEnded { - store.send(.view(.didTapPlayer)) + .pictureInPictureStatus { status in + pipStatus = status } - ) - #if os(iOS) - .statusBarHidden(viewStore.state) - .animation(.easeInOut, value: viewStore.state) - #endif + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + .ignoresSafeArea(.all, edges: .all) + .contentShape(Rectangle()) + .gesture( + MagnificationGesture() + .onEnded { scale in + if scale < 1 { + gravity = .resizeAspect + } else { + gravity = .resizeAspectFill + } + } + ) + .gesture(SimultaneousGesture(TapGesture(count: 2), TapGesture(count: 1)) + .simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .local) + ).onEnded { value in + if (value.first?.first != nil) { + if proxy.size.width / 2 > (value.second?.location.x ?? .zero) { + rateBufferingState.send(.view(.didSkipBackwards)) + } else { + rateBufferingState.send(.view(.didSkipForward)) + } + } else { + store.send(.view(.didTapPlayer)) + } + }) + #if os(iOS) + .statusBarHidden(viewStore.state) + .animation(.easeInOut, value: viewStore.state) + #endif + } + } } } .overlay { @@ -112,13 +123,37 @@ extension VideoPlayerFeature.View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background { if viewStore.state { - Color.black - .opacity(0.35) - .ignoresSafeArea() - .edgesIgnoringSafeArea(.all) - .onTapGesture { - store.send(.view(.didTapPlayer)) + GeometryReader { proxy in + WithViewStore(store, observe: RateBufferingState.init) { rateBufferingState in + Color.black + .opacity(0.35) + .ignoresSafeArea() + .edgesIgnoringSafeArea(.all) + .gesture( + MagnificationGesture() + .onEnded { scale in + if scale < 1 { + gravity = .resizeAspect + } else { + gravity = .resizeAspectFill + } + } + ) + .gesture(SimultaneousGesture(TapGesture(count: 2), TapGesture(count: 1)) + .simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .local) + ).onEnded { value in + if (value.first?.first != nil) { + if proxy.size.width / 2 > (value.second?.location.x ?? .zero) { + rateBufferingState.send(.view(.didSkipBackwards)) + } else { + rateBufferingState.send(.view(.didSkipForward)) + } + } else { + store.send(.view(.didTapPlayer)) + } + }) } + } } } .animation(.easeInOut, value: viewStore.state) @@ -229,6 +264,9 @@ extension VideoPlayerFeature.View { // Text(viewStore.playlist.title ?? "No title") // .font(.footnote) } + .onTapGesture { + store.send(.view(.didTapBackButton)) + } } Spacer() @@ -307,7 +345,7 @@ extension VideoPlayerFeature.View { WithViewStore(store, observe: \.videoPlayerStatus == nil) { canShowControls in if canShowControls.state { WithViewStore(store, observe: RateBufferingState.init) { rateBufferingState in - HStack(spacing: 0) { + HStack(spacing: 10) { Spacer() Button { @@ -341,7 +379,7 @@ extension VideoPlayerFeature.View { .frame(width: 54, height: 54) Button { - rateBufferingState.send(.view(.didSkipFowards)) + rateBufferingState.send(.view(.didSkipForward)) } label: { Image(systemName: "goforward") .font(.title2.weight(.bold)) diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index 9f4f0d2..2a4c6af 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -134,7 +134,7 @@ public struct VideoPlayerFeature: Feature { case didSelectMoreTab(State.Overlay.MoreTab) case didTapCloseMoreOverlay case didTogglePlayback - case didSkipFowards + case didSkipForward case didSkipBackwards case didChangePlaybackRate(Double) case didSkipTo(time: CGFloat)