diff --git a/.gitignore b/.gitignore index a091ed5..0f473d9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ RIPVessel.xcodeproj/xcshareddata/xcschemes/RIPVessel.xcscheme RIPVessel.xcodeproj/xcuserdata/gyulakiri.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist + +DerivedData/ diff --git a/RIPVessel.xcodeproj/project.pbxproj b/RIPVessel.xcodeproj/project.pbxproj index 96e1e28..aa04a7b 100644 --- a/RIPVessel.xcodeproj/project.pbxproj +++ b/RIPVessel.xcodeproj/project.pbxproj @@ -287,10 +287,11 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = X5393PZPXX; + DEVELOPMENT_TEAM = N4FSUVZ5W3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RIPVessel/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LessevPIR; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = Lau; @@ -302,7 +303,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = hu.gyulakiri.RIPVessel; + PRODUCT_BUNDLE_IDENTIFIER = hu.gyulakiri.lessevpir; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -321,10 +322,11 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = X5393PZPXX; + DEVELOPMENT_TEAM = N4FSUVZ5W3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RIPVessel/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LessevPIR; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = Lau; @@ -336,7 +338,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = hu.gyulakiri.RIPVessel; + PRODUCT_BUNDLE_IDENTIFIER = hu.gyulakiri.lessevpir; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; diff --git a/RIPVessel/AppDelegate.swift b/RIPVessel/AppDelegate.swift new file mode 100644 index 0000000..af20baf --- /dev/null +++ b/RIPVessel/AppDelegate.swift @@ -0,0 +1,59 @@ +// +// AppDelegate.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// +import Foundation +import UIKit +#if DUSZMOX +import Shake +#endif + +class AppDelegate: NSObject, UIApplicationDelegate { + + static var orientationLock = UIInterfaceOrientationMask.allButUpsideDown { + didSet { + print("Changed orientation lock to: \(orientationLock)") + } + } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return AppDelegate.orientationLock + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let env = ProcessInfo.processInfo.environment + loadRocketSimConnect() + #if DUSZMOX + Shake.start(apiKey: env["SHAKE_API_KEY"] ?? "") + #endif + return true + } + + static func rotateScreen(to orientation: UIInterfaceOrientationMask) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) { error in + if let err = error as? UISceneError, err.code == .geometryRequestUnsupported { + AppDelegate.orientationLock = .allButUpsideDown + DispatchQueue.main.async { + self.rotateScreen(to: orientation) + } + } + print("Error updating geometry: \(error)") + } + } + + private func loadRocketSimConnect() { + #if DEBUG + guard (Bundle(path: "/Applications/RocketSim.app/Contents/Frameworks/RocketSimConnectLinker.nocache.framework")?.load() == true) else { + print("Failed to load linker framework") + return + } + print("RocketSim Connect successfully linked") + #endif + } + +} diff --git a/RIPVessel/Extensions/View+RIPVessel.swift b/RIPVessel/Extensions/View+RIPVessel.swift index 31e1dd0..ab6f0be 100644 --- a/RIPVessel/Extensions/View+RIPVessel.swift +++ b/RIPVessel/Extensions/View+RIPVessel.swift @@ -5,7 +5,7 @@ // Created by Gyula Kiri on 2024. 07. 27.. // -import SwiftUICore +import SwiftUI import UIKit struct DeviceRotationViewModifier: ViewModifier { @@ -53,6 +53,23 @@ extension View { .onPreferenceChange(ViewHeightKey.self, perform: onChange) } } +///Extension for TabView items +extension View { + @ViewBuilder + func setupTab(_ tab: Tab) -> some View { + self + .tag(tab) + .toolbar(.hidden, for: .tabBar) + } + + /// SafeArea Value + var safeArea: UIEdgeInsets { + if let safeArea = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow?.safeAreaInsets { + return safeArea + } + return .zero + } +} #if canImport(UIKit) diff --git a/RIPVessel/Info.plist b/RIPVessel/Info.plist index 607729e..f753731 100644 --- a/RIPVessel/Info.plist +++ b/RIPVessel/Info.plist @@ -5,7 +5,6 @@ UIBackgroundModes audio - video diff --git a/RIPVessel/Model/PlayerConfig.swift b/RIPVessel/Model/PlayerConfig.swift new file mode 100644 index 0000000..6547d52 --- /dev/null +++ b/RIPVessel/Model/PlayerConfig.swift @@ -0,0 +1,21 @@ +// +// PlayerConfig.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// +import SwiftUI + +struct PlayerConfig: Equatable { + var position: CGFloat = .zero + var lastPosition: CGFloat = .zero + var progress: CGFloat = .zero + var selectedPlayerItem: Components.Schemas.BlogPostModelV3? + var showMiniPlayer: Bool = false + + mutating func resetPosition() { + position = .zero + lastPosition = .zero + progress = .zero + } +} diff --git a/RIPVessel/Model/Tab.swift b/RIPVessel/Model/Tab.swift new file mode 100644 index 0000000..0d5dc48 --- /dev/null +++ b/RIPVessel/Model/Tab.swift @@ -0,0 +1,26 @@ +// +// Tab.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// + +import SwiftUI + +/// Tab's +enum Tab: String, CaseIterable { + case recents = "Recents" + case creators = "Creators" + case you = "You" + + var symbol: String { + switch self { + case .recents: + "house.fill" + case .creators: + "books.vertical.fill" + case .you: + "person.circle.fill" + } + } +} diff --git a/RIPVessel/RIPVesselApp.swift b/RIPVessel/RIPVesselApp.swift index be51fe4..71bd99d 100644 --- a/RIPVessel/RIPVesselApp.swift +++ b/RIPVessel/RIPVesselApp.swift @@ -6,46 +6,10 @@ // import SwiftUI -import Shake - -class AppDelegate: NSObject, UIApplicationDelegate { - - static var orientationLock = UIInterfaceOrientationMask.portrait - - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - return AppDelegate.orientationLock - } - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - let env = ProcessInfo.processInfo.environment - #if DUSZMOX - Shake.start(apiKey: env["SHAKE_API_KEY"] ?? "") - #endif - return true - } - - static func rotateScreen(to orientation: UIInterfaceOrientationMask) { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } - windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) { error in - if let err = error as? UISceneError, err.code == .geometryRequestUnsupported { - AppDelegate.orientationLock = .allButUpsideDown - DispatchQueue.main.async { - self.rotateScreen(to: orientation) - } - } - print("Error updating geometry: \(error)") - } - } -} @main struct RIPVesselApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - init() { - loadRocketSimConnect() - } var body: some Scene { WindowGroup { MainView().environmentObject(AuthService.shared) @@ -53,49 +17,3 @@ struct RIPVesselApp: App { } } } - -struct MainView: View { - @EnvironmentObject var auth: AuthService - @StateObject private var router = Router.shared - - var body: some View { - if auth.loggedIn { - TabView { - NavigationStack { - RecentsPageView() - }.tabItem { - Label("Recents", systemImage: "play.house.fill") - } - NavigationStack { - CreatorsView() - }.tabItem { - Label("Creators", systemImage: "books.vertical.fill") - } - NavigationStack { - AccountView() - }.tabItem { - Label("Account", systemImage: "person.crop.circle.fill") - } - } - } else { - LoginView() - } - } -} - -struct LoadingView: View { - var body: some View { - Text("Loading...") - .padding() - } -} - -private func loadRocketSimConnect() { - #if DEBUG - guard (Bundle(path: "/Applications/RocketSim.app/Contents/Frameworks/RocketSimConnectLinker.nocache.framework")?.load() == true) else { - print("Failed to load linker framework") - return - } - print("RocketSim Connect successfully linked") - #endif -} diff --git a/RIPVessel/ViewModels/ChannelViewModel.swift b/RIPVessel/ViewModels/ChannelViewModel.swift index f984341..1bf947f 100644 --- a/RIPVessel/ViewModels/ChannelViewModel.swift +++ b/RIPVessel/ViewModels/ChannelViewModel.swift @@ -6,13 +6,16 @@ // import Foundation +import SwiftUI extension ChannelView { class ViewModel: ObservableObject { @Published var creator: Components.Schemas.CreatorModelV3? @Published var channelId: String + @Binding var playerConfig: PlayerConfig - init(creatorId: String, channelId: String) { + init(creatorId: String, channelId: String, playerConfig: Binding) { + _playerConfig = playerConfig self.channelId = channelId Task { [weak self] in guard let self = self else { return } diff --git a/RIPVessel/ViewModels/VideoViewModel.swift b/RIPVessel/ViewModels/VideoViewModel.swift index e1684aa..251184c 100644 --- a/RIPVessel/ViewModels/VideoViewModel.swift +++ b/RIPVessel/ViewModels/VideoViewModel.swift @@ -15,11 +15,25 @@ extension VideoView { @Published var currentQuality: Components.Schemas.CdnDeliveryV3Variant? @Published var video: Components.Schemas.ContentVideoV3Response? @Published var description: String + @Published var isHidden: Bool = false - init(post: Components.Schemas.BlogPostModelV3) { - description = post.text - Task { - await initialize(post: post) + init(post: Components.Schemas.BlogPostModelV3?) { + description = post?.text ?? "" + if let post { + Task { + await initialize(post: post) + } + } + } + + func updatePost(_ post: Components.Schemas.BlogPostModelV3?) { + description = post?.text ?? "" + self.post?.title = post?.title ?? "" + isHidden = true + if let post { + Task { + await initialize(post: post) + } } } @@ -30,6 +44,9 @@ extension VideoView { self.post = fetchedPost await fetchVideoContent() await fetchDeliveryInfo() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.isHidden = false + } } catch { print("Error initializing ViewModel: \(error)") } @@ -106,7 +123,6 @@ extension VideoView { } func uploadProgress(_ progress: Double) { - print(progress) guard let video else {return} Task { try await ApiService.shared.client.updateProgress(body: Operations.updateProgress.Input.Body.json(Components.Schemas.UpdateProgressRequest(id: video.id, contentType: .video, progress: Int(progress)))) diff --git a/RIPVessel/Views/Channel/ChannelView.swift b/RIPVessel/Views/Channel/ChannelView.swift index 69b2ba7..4ce3556 100644 --- a/RIPVessel/Views/Channel/ChannelView.swift +++ b/RIPVessel/Views/Channel/ChannelView.swift @@ -11,9 +11,9 @@ struct ChannelView: View { let id: String @StateObject var vm: ViewModel - init(id: String, creatorId: String) { + init(id: String, creatorId: String, playerConfig: Binding) { self.id = id - _vm = .init(wrappedValue: ViewModel(creatorId: creatorId, channelId: id)) + _vm = .init(wrappedValue: ViewModel(creatorId: creatorId, channelId: id, playerConfig: playerConfig)) } @@ -37,7 +37,7 @@ struct ChannelView: View { .foregroundColor(.white) }.frame(alignment: .leading) } - RecentsView(creatorId: self.vm.creator!.id, channelId: id, scrollEnabled: .constant(false)) + RecentsView(creatorId: self.vm.creator!.id, channelId: id, scrollEnabled: .constant(false), playerConfig: $vm.playerConfig) } }.frame(maxHeight: .infinity) } diff --git a/RIPVessel/Views/Components/LoadingView.swift b/RIPVessel/Views/Components/LoadingView.swift new file mode 100644 index 0000000..9be342b --- /dev/null +++ b/RIPVessel/Views/Components/LoadingView.swift @@ -0,0 +1,14 @@ +// +// LoadingView.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// +import SwiftUI + +struct LoadingView: View { + var body: some View { + Text("Loading...") + .padding() + } +} \ No newline at end of file diff --git a/RIPVessel/Views/MainView/CreatorsView.swift b/RIPVessel/Views/MainView/CreatorsView.swift index 8b2bebd..982a2e4 100644 --- a/RIPVessel/Views/MainView/CreatorsView.swift +++ b/RIPVessel/Views/MainView/CreatorsView.swift @@ -10,7 +10,7 @@ import SwiftData struct CreatorsView: View { @StateObject private var vm = ViewModel() - + @Binding var playerConfig: PlayerConfig var body: some View { VStack { ScrollView { @@ -27,7 +27,7 @@ struct CreatorsView: View { ForEach(creator.channels, id: \.id) { channel in HStack { NavigationLink { - ChannelView(id: channel.id, creatorId: creator.id) + ChannelView(id: channel.id, creatorId: creator.id, playerConfig: $playerConfig) } label: { IconView(url: channel.icon.path, size: CGSize(width: 33, height: 33)).frame(width: 33, height: 33).clipShape(Circle()) Text(channel.title) diff --git a/RIPVessel/Views/MainView/MainView.swift b/RIPVessel/Views/MainView/MainView.swift new file mode 100644 index 0000000..67e9768 --- /dev/null +++ b/RIPVessel/Views/MainView/MainView.swift @@ -0,0 +1,92 @@ +// +// MainView.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// +import SwiftUI + +struct MainView: View { + @EnvironmentObject var auth: AuthService + @StateObject private var router = Router.shared + + @State private var activeTab: Tab = .recents + @State private var config: PlayerConfig = .init() + var body: some View { + if auth.loggedIn { + ZStack(alignment: .bottom) { + TabView (selection: $activeTab) { + NavigationStack { + RecentsPageView(playerConfig: $config) + }.setupTab(.recents) + NavigationStack { + CreatorsView(playerConfig: $config) + }.setupTab(.creators) + NavigationStack { + AccountView() + }.setupTab(.you) + }.padding(.bottom, tabBarHeight(withSafeArea: true)) + + /// MiniPlayer View + GeometryReader { + let size = $0.size + + if config.showMiniPlayer { + MiniPlayerView(size: size, config: $config) { + withAnimation(.easeInOut(duration: 0.3)) { + config.showMiniPlayer = false + } + // Use a DispatchQueue to handle completion actions explicitly + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + config.resetPosition() + config.selectedPlayerItem = nil + } + } + } + } + + CustomTabBar() + .offset(y: config.showMiniPlayer ? tabBarHeight(withSafeArea: true) - (config.progress * tabBarHeight(withSafeArea: true)) : 0) + }.ignoresSafeArea(.all, edges: .bottom) + + } else { + LoginView() + } + } + + /// Custom Tab Bar + @ViewBuilder + func CustomTabBar() -> some View { + HStack(spacing: 0) { + ForEach(Tab.allCases, id: \.rawValue) { tab in + VStack(spacing: 4) { + Image(systemName: tab.symbol) + .font(.title3) + Text(tab.rawValue) + .font(.caption2) + } + .foregroundStyle(activeTab == tab ? Color.primary : .gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(.rect) + .onTapGesture { + activeTab = tab + } + } + } + .frame(height: tabBarHeight()) + .overlay(alignment: .top) { + Divider() + } + .frame(maxHeight: .infinity, alignment: .top) + .frame(height: tabBarHeight(withSafeArea: true)) + .background(.background) + } + + private func tabBarHeight(withSafeArea: Bool = false) -> CGFloat { + if withSafeArea { + return 49 + safeArea.bottom + } else { + return 49 + } + } +} diff --git a/RIPVessel/Views/MainView/MiniPlayerView.swift b/RIPVessel/Views/MainView/MiniPlayerView.swift new file mode 100644 index 0000000..cd6ac95 --- /dev/null +++ b/RIPVessel/Views/MainView/MiniPlayerView.swift @@ -0,0 +1,303 @@ +// +// MiniPlayerView.swift +// RIPVessel +// +// Created by Gyula Kiri on 2024. 12. 09.. +// + +import SwiftUI + +// MARK: - Main MiniPlayerView + +struct MiniPlayerView: View { + var size: CGSize + @Binding var config: PlayerConfig + var close: () -> Void + + // Fixed sizes and computed heights + private let collapsedVideoWidth: CGFloat = 120 + let miniPlayerHeight: CGFloat + let playerHeight: CGFloat + private var tabBarHeight: CGFloat { + safeArea.bottom + CGFloat(49) + } + + @StateObject private var vm: VideoView.ViewModel + @State private var isRotated = false + + init(size: CGSize, config: Binding, close: @escaping () -> Void, isRotated: Bool = false) { + self.size = size + _config = config + self.close = close + self.isRotated = isRotated + self.miniPlayerHeight = collapsedVideoWidth * (CGFloat(9) / CGFloat(16)) + // Instantiate the view model using the selected player item + _vm = StateObject(wrappedValue: VideoView.ViewModel(post: config.wrappedValue.selectedPlayerItem)) + self.playerHeight = size.width * (CGFloat(9) / CGFloat(16)) + } + + var body: some View { + GeometryReader { geometry in + // Compute progress values that drive the mini player layout and overlays + let clampedProgress: CGFloat = max(min(config.progress, CGFloat(1)), CGFloat(0)) + let overlayProgress: CGFloat = clampedProgress > CGFloat(0.7) ? (clampedProgress - CGFloat(0.7)) / CGFloat(0.3) : 0 + + VStack(spacing: 0) { + // Top section: video content + overlay controls + ZStack(alignment: isRotated ? .center : .top) { + MiniPlayerVideoContentView( + stream: vm.stream, + geometry: geometry, + progress: clampedProgress, + collapsedWidth: collapsedVideoWidth, + collapsedHeight: miniPlayerHeight, + isRotated: $isRotated, + vm: vm, + config: $config, + ) + MiniPlayerOverlayView( + vm: vm, + close: close, + progress: overlayProgress, + collapsedWidth: collapsedVideoWidth + ) + } + .frame(minHeight: miniPlayerHeight, maxHeight: playerHeight) + .zIndex(1) + .onRotate { orientation in + // Ignore upside-down and face-up orientations + if orientation == .portraitUpsideDown || orientation == .faceUp { return } + isRotated = orientation == .landscapeLeft || orientation == .landscapeRight + } + + // Bottom section: detailed view (scrollable) + MiniPlayerDetailView( + vm: vm, + isRotated: isRotated, + progress: clampedProgress + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(isRotated ? Color.black : Color(.systemBackground)) + .clipped() + .contentShape(Rectangle()) + .offset(y: config.progress * -(safeArea.bottom + CGFloat(49))) + .frame(height: geometry.size.height - config.position, alignment: .top) + .frame(maxHeight: .infinity, alignment: .bottom) + .gesture( + DragGesture() + .onChanged { value in + let start = value.startLocation.y + let canDragFromStart = isRotated || + start < playerHeight || + start > (geometry.size.height - (tabBarHeight + miniPlayerHeight)) + // Ensure the gesture only triggers in the top or bottom regions + guard canDragFromStart + else { return } + if isRotated { + withAnimation(.easeInOut(duration: 0.2)) { + isRotated = false + } + AppDelegate.rotateScreen(to: .portrait) + } + let height = config.lastPosition + value.translation.height + let clampedHeight = max(.zero, min(height, geometry.size.height - miniPlayerHeight)) + config.position = clampedHeight + generateProgress(size: geometry.size) + } + .onEnded { value in + let start = value.startLocation.y + let canDragFromStart = isRotated || + start < playerHeight || + start > (geometry.size.height - (tabBarHeight + miniPlayerHeight)) + guard canDragFromStart + else { return } + let velocity = value.velocity.height * CGFloat(5) + withAnimation(.smooth(duration: 0.3)) { + if (config.position + velocity) > (geometry.size.height * CGFloat(0.65)) { + if isRotated { + withAnimation(.easeInOut(duration: 0.2)) { + isRotated = false + } + AppDelegate.rotateScreen(to: .portrait) + } + config.position = (geometry.size.height - miniPlayerHeight) + config.lastPosition = config.position + config.progress = CGFloat(1) + } else { + config.resetPosition() + } + } + } + .simultaneously(with: TapGesture().onEnded { _ in + withAnimation(.smooth(duration: 0.3)) { + AppDelegate.orientationLock = .allButUpsideDown + AppDelegate.rotateScreen(to: .portrait) + config.resetPosition() + } + }) + ) + .transition(.offset(y: config.progress == CGFloat(1) ? tabBarHeight : geometry.size.height)) + .onChange(of: config.selectedPlayerItem) { newValue in + vm.updatePost(newValue) + withAnimation(.smooth(duration: 0.3)) { + config.resetPosition() + AppDelegate.orientationLock = .allButUpsideDown + AppDelegate.rotateScreen(to: .portrait) + } + } + .onAppear { + AppDelegate.orientationLock = .allButUpsideDown + AppDelegate.rotateScreen(to: .portrait) + } + .onDisappear { + AppDelegate.orientationLock = .portrait + AppDelegate.rotateScreen(to: .portrait) + } + .ignoresSafeArea(isRotated ? .all : .container) + } + } + + // Helper method for updating the progress based on drag gesture and available height. + func generateProgress(size: CGSize) { + let progress = max(min(config.position / (size.height - miniPlayerHeight), CGFloat(1)), .zero) + config.progress = progress + } +} + +// MARK: - Subview: Video Content + +struct MiniPlayerVideoContentView: View { + var stream: Components.Schemas.CdnDeliveryV3Response? + var geometry: GeometryProxy + var progress: CGFloat + var collapsedWidth: CGFloat + var collapsedHeight: CGFloat + @Binding var isRotated: Bool + @ObservedObject var vm: VideoView.ViewModel + @Binding var config: PlayerConfig + + var body: some View { + if let stream = stream { + let size = geometry.size + let expandedWidth = size.width + let expandedHeight = size.width * (CGFloat(9) / CGFloat(16)) + let videoWidth = expandedWidth - (expandedWidth - collapsedWidth) * progress + let videoHeight = expandedHeight - (expandedHeight - collapsedHeight) * progress + let alignment: Alignment = isRotated ? .center : .leading + + VideoPlayerWrapperView( + videoURL: stream.groups.first?.origins?.first?.url ?? "", + currentQuality: $vm.currentQuality, + qualities: vm.qualities, + size: size, + safeArea: EdgeInsets( + top: safeArea.top, + leading: safeArea.left, + bottom: safeArea.bottom, + trailing: safeArea.right + ), + isRotated: $isRotated, + title: vm.video?.title ?? "", + initialProgress: vm.video?.progress, + playerConfig: $config, + observeProgress: { p in + vm.uploadProgress(p) + } + ) + .frame(width: videoWidth, height: videoHeight) + .opacity(vm.isHidden ? 0 : 1) + .frame( + maxWidth: .infinity, + maxHeight: isRotated ? .infinity : nil, + alignment: alignment + ) + } + } +} + +// MARK: - Subview: Overlay (Title and Controls) + +struct MiniPlayerOverlayView: View { + @ObservedObject var vm: VideoView.ViewModel + var close: () -> Void + var progress: CGFloat + var collapsedWidth: CGFloat + + var body: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(vm.video?.title ?? "") + .font(.callout) + .lineLimit(1) + Text(vm.post?.channel.title ?? "") + .font(.caption) + .foregroundStyle(.gray) + } + Spacer() + Button(action: {}, label: { + Image(systemName: "pause.fill") + .font(.title2) + .frame(width: 35, height: 35) + }) + Button(action: close, label: { + Image(systemName: "xmark") + .font(.title2) + .frame(width: 35, height: 35) + }) + } + .padding(.leading, collapsedWidth + CGFloat(10)) + .padding(.trailing, CGFloat(15)) + .foregroundStyle(Color.primary) + .opacity(progress) + } +} + +// MARK: - Subview: Details + +struct MiniPlayerDetailView: View { + @ObservedObject var vm: VideoView.ViewModel + let isRotated: Bool + let progress: CGFloat + + var body: some View { + if let post = vm.post { + let detailOpacity = max(CGFloat.zero, CGFloat(1) - (progress * CGFloat(1.6))) + ScrollView { + VStack { + HStack { + Text(vm.video?.title ?? "") + .font(.title) + .bold() + .padding() + Spacer() + } + HStack { + Button { + vm.like() + } label: { + Image(systemName: (post.userInteraction?.contains(.like) ?? false) + ? "hand.thumbsup.fill" : "hand.thumbsup") + Text(String(post.likes)) + } + Button { + vm.dislike() + } label: { + Image(systemName: (post.userInteraction?.contains(.dislike) ?? false) + ? "hand.thumbsdown.fill" : "hand.thumbsdown") + Text(String(post.dislikes)) + } + Spacer() + } + .padding() + CollapsibleAsyncAttributedTextView(htmlString: vm.description) + .padding() + Spacer() + } + } + // Hide the detail view when rotated; adjust opacity with progress. + .frame(height: isRotated ? CGFloat.zero : nil) + .opacity(detailOpacity) + } + } +} diff --git a/RIPVessel/Views/MainView/Recents/Components/RecentPostView.swift b/RIPVessel/Views/MainView/Recents/Components/RecentPostView.swift index b4c6708..4407aca 100644 --- a/RIPVessel/Views/MainView/Recents/Components/RecentPostView.swift +++ b/RIPVessel/Views/MainView/Recents/Components/RecentPostView.swift @@ -10,24 +10,29 @@ import SwiftUI struct RecentPostView: View { @StateObject private var vm: ViewModel @StateObject private var router = Router.shared + @Binding var playerConfig: PlayerConfig var progress: Int? var updateProgress: (String) -> Void + var onTap: () -> Void - init(post: Components.Schemas.BlogPostModelV3, isSpecificChannel: Bool = false, progress: Int?, updateProgress: @escaping (String) -> Void) { + init(post: Components.Schemas.BlogPostModelV3, isSpecificChannel: Bool = false, progress: Int?, playerConfig: Binding, updateProgress: @escaping (String) -> Void, onTap: @escaping () -> Void) { _vm = StateObject(wrappedValue: ViewModel(post: post, isSpecificChannel: isSpecificChannel)) self.progress = progress self.updateProgress = updateProgress + self.onTap = onTap + _playerConfig = playerConfig } var body: some View { VStack(alignment: .leading) { if let thumbnail = vm.post.thumbnail?.value1.path { - NavigationLink { - VideoView(post: vm.post, updateProgress: updateProgress) - } label: { + ZStack(alignment: .bottomTrailing) { IconView(url: thumbnail) .aspectRatio(16/9, contentMode: .fit) + .onTapGesture { + onTap() + } HStack { if vm.post.metadata.hasVideo { let text = vm.post.metadata.videoDuration.asString(style: .positional) @@ -52,18 +57,16 @@ struct RecentPostView: View { .frame(width: geometry.size.width * (CGFloat(progress) / 100), height: 10) } - } .frame(height: 10) } }.cornerRadius(8).padding([.leading, .trailing], 4) - } } HStack(alignment: .top, spacing: 10) { if let channel = vm.getChannelModel(from: vm.post.channel) { NavigationLink { - ChannelView(id: channel.id, creatorId: vm.post.creator.id ) + ChannelView(id: channel.id, creatorId: vm.post.creator.id, playerConfig: $playerConfig) } label: { IconView(url: channel.icon.path ) .frame(width: 40, height: 40) @@ -84,9 +87,6 @@ struct RecentPostView: View { Text("\(vm.getChannelModel(from: vm.post.channel)?.title ?? "") • \(vm.post.releaseDate.timeAgoString())") .font(.subheadline) .foregroundColor(.secondary) - .onTapGesture { - print("channel clicked") - } } } .padding([.horizontal, .bottom], 8) diff --git a/RIPVessel/Views/MainView/Recents/RecentsPageView.swift b/RIPVessel/Views/MainView/Recents/RecentsPageView.swift index 09c70da..008b933 100644 --- a/RIPVessel/Views/MainView/Recents/RecentsPageView.swift +++ b/RIPVessel/Views/MainView/Recents/RecentsPageView.swift @@ -10,21 +10,23 @@ import SwiftUI struct RecentsPageView: View { @State private var scrollEnabled: Bool = false @State private var shouldRefresh: Bool = false + @Binding var playerConfig: PlayerConfig var body: some View { ScrollView { - RecentsView(scrollEnabled: $scrollEnabled, shouldRefresh: $shouldRefresh) + RecentsView(scrollEnabled: $scrollEnabled, shouldRefresh: $shouldRefresh, playerConfig: $playerConfig) }.refreshable { shouldRefresh = true } .scrollDisabled(!scrollEnabled) - .onAppear { - AppDelegate.rotateScreen(to: .portrait) - AppDelegate.orientationLock = .portrait - }.onDisappear { - AppDelegate.orientationLock = .allButUpsideDown - AppDelegate.rotateScreen(to: .portrait) - }.navigationTitle("Recent Posts") +// .onAppear { +// AppDelegate.rotateScreen(to: .portrait) +// AppDelegate.orientationLock = .portrait +// }.onDisappear { +// AppDelegate.orientationLock = .allButUpsideDown +// AppDelegate.rotateScreen(to: .portrait) +// } + .navigationTitle("Recent Posts") } } diff --git a/RIPVessel/Views/MainView/Recents/RecentsView.swift b/RIPVessel/Views/MainView/Recents/RecentsView.swift index 59b151f..7effd75 100644 --- a/RIPVessel/Views/MainView/Recents/RecentsView.swift +++ b/RIPVessel/Views/MainView/Recents/RecentsView.swift @@ -11,14 +11,15 @@ struct RecentsView: View { @StateObject private var vm: ViewModel @Binding var scrollEnabled: Bool @Binding var shouldRefresh: Bool - + @Binding var playerConfig: PlayerConfig @State var isSpecificChannel: Bool - init(creatorId: String? = nil, channelId: String? = nil, scrollEnabled: Binding = .constant(true), shouldRefresh: Binding = .constant(false)) { + init(creatorId: String? = nil, channelId: String? = nil, scrollEnabled: Binding = .constant(true), shouldRefresh: Binding = .constant(false), playerConfig: Binding) { _vm = .init(wrappedValue: .init(creatorId: creatorId, channelId: channelId)) _scrollEnabled = scrollEnabled _isSpecificChannel = .init(initialValue: creatorId != nil || channelId != nil) _shouldRefresh = shouldRefresh + _playerConfig = playerConfig } var body: some View { @@ -29,7 +30,13 @@ struct RecentsView: View { LoadingRecentPostView() } ForEach(vm.recents, id: \.id) { recent in - RecentPostView(post: recent, isSpecificChannel: isSpecificChannel, progress: vm.progresses[recent.id], updateProgress: vm.updateProgress) + RecentPostView(post: recent, isSpecificChannel: isSpecificChannel, progress: vm.progresses[recent.id], playerConfig: $playerConfig, updateProgress: vm.updateProgress) { + playerConfig.selectedPlayerItem = recent + withAnimation(.easeInOut(duration: 0.3)) { + playerConfig.showMiniPlayer = true + } + } + .onAppear { Task { if recent.id == vm.recents.last?.id { diff --git a/RIPVessel/Views/Video/VideoPlayerWrapperView.swift b/RIPVessel/Views/Video/VideoPlayerWrapperView.swift index b657bd9..d608dee 100644 --- a/RIPVessel/Views/Video/VideoPlayerWrapperView.swift +++ b/RIPVessel/Views/Video/VideoPlayerWrapperView.swift @@ -21,6 +21,7 @@ struct VideoPlayerWrapperView: View { @State private var currentTime: Double = 0 @State private var duration: Double = 0 @State private var isObserverAdded: Bool = false + @Binding private var playerConfig: PlayerConfig let initialProgress: Int? let videoURL: String @@ -35,6 +36,8 @@ struct VideoPlayerWrapperView: View { @StateObject private var vm: PlayerViewModel var observeProgress: (Double) -> Void + @State var aspectRatio: CGFloat = 0 + @State var desiredHeight: CGFloat init( videoURL: String, @@ -45,6 +48,7 @@ struct VideoPlayerWrapperView: View { isRotated: Binding, title: String, initialProgress: Int?, + playerConfig: Binding, observeProgress: @escaping (Double) -> Void ) { self.videoURL = videoURL @@ -58,13 +62,30 @@ struct VideoPlayerWrapperView: View { _vm = StateObject(wrappedValue: PlayerViewModel(url: url)) self.observeProgress = observeProgress self.initialProgress = initialProgress + _playerConfig = playerConfig + let aspectRatio: Double = Double(currentQuality.wrappedValue!.meta!.video!.value2.width!) / Double(currentQuality.wrappedValue!.meta!.video!.value2.height!) + + _aspectRatio = .init(initialValue: aspectRatio) + _desiredHeight = .init(initialValue: size.width / aspectRatio) } var body: some View { - let videoPlayerSize: CGSize = .init( - width: isRotated ? size.height + safeArea.bottom + safeArea.top : size.width, - height: isRotated ? size.width + safeArea.leading + safeArea.trailing : .zero - ) + let miniPlayerProgress = max(min(playerConfig.progress, CGFloat(1)), CGFloat(0)) + let collapsedWidth: CGFloat = 120 + let collapsedHeight = collapsedWidth / max(aspectRatio, CGFloat(0.1)) + + let videoPlayerSize: CGSize = { + if isRotated { + return .init( + width: size.height + safeArea.bottom + safeArea.top, + height: size.width + safeArea.leading + safeArea.trailing + ) + } else { + let width = size.width - (size.width - collapsedWidth) * miniPlayerProgress + let targetHeight = desiredHeight - (desiredHeight - collapsedHeight) * miniPlayerProgress + return .init(width: width, height: max(collapsedHeight, targetHeight)) + } + }() ZStack(alignment: .center) { VideoPlayerView( @@ -81,6 +102,7 @@ struct VideoPlayerWrapperView: View { videoOverlays } .onTapGesture { + guard playerConfig.progress != CGFloat(1) else { return } withAnimation(.easeInOut(duration: 0.35)) { showPlayerControls.toggle() } @@ -113,7 +135,8 @@ struct VideoPlayerWrapperView: View { } } ) - .offset(y: isRotated ? -15 : 0) + .offset(y: isRotated ? CGFloat(-15) : .zero) + .opacity(playerConfig.progress == .zero ? 1 : 0) } .overlay(alignment: .bottom) { BottomControlsView( @@ -126,7 +149,7 @@ struct VideoPlayerWrapperView: View { safeArea: safeArea, toggleRotation: toggleRotation ) - .offset(y: isRotated ? -15 : 0) + .offset(y: isRotated ? CGFloat(-15) : .zero) } .overlay(alignment: .top) { TopControlsView( @@ -157,23 +180,23 @@ struct VideoPlayerWrapperView: View { .background { Rectangle().fill(Color.black) } - .gesture( - DragGesture().onEnded { value in - if -value.translation.height > 100 { - withAnimation(.easeInOut(duration: 0.2)) { - isRotated = true - } - AppDelegate.rotateScreen(to: .landscape) - } else { - withAnimation(.easeInOut(duration: 0.2)) { - isRotated = false - } - AppDelegate.rotateScreen(to: .portrait) - } - } - ) - .frame(width: videoPlayerSize.width, height: isRotated ? videoPlayerSize.height : nil) - .frame(width: size.width) +// .gesture( +// DragGesture().onEnded { value in +// if -value.translation.height > 100 { +// withAnimation(.easeInOut(duration: 0.2)) { +// isRotated = true +// } +// AppDelegate.rotateScreen(to: .landscape) +// } else { +// withAnimation(.easeInOut(duration: 0.2)) { +// isRotated = false +// } +// AppDelegate.rotateScreen(to: .portrait) +// } +// } +// ) + .frame(width: videoPlayerSize.width, height: videoPlayerSize.height) +// .frame(width: size.width) .zIndex(10000) .onChange(of: currentQuality) { newQuality in let newURL = URL(string: videoURL + (newQuality?.url ?? ""))! @@ -181,6 +204,11 @@ struct VideoPlayerWrapperView: View { vm.updatePlayerItem(url: newURL) vm.player.seek(to: .init(seconds: currentTime, preferredTimescale: 1)) } + .onChange(of: playerConfig.progress) { newProgress in + if newProgress >= 1 || newProgress <= 0 { + showPlayerControls = false + } + } .onAppear { AppDelegate.orientationLock = .allButUpsideDown setupPlayerObserver() @@ -292,11 +320,13 @@ struct VideoPlayerWrapperView: View { func toggleRotation() { if isRotated { + withAnimation(.easeInOut(duration: 0.2)) { isRotated = false } AppDelegate.rotateScreen(to: .portrait) } else { + withAnimation(.easeInOut(duration: 0.2)) { isRotated = true } diff --git a/RIPVessel/Views/Video/VideoView.swift b/RIPVessel/Views/Video/VideoView.swift index fabc25c..e8a725b 100644 --- a/RIPVessel/Views/Video/VideoView.swift +++ b/RIPVessel/Views/Video/VideoView.swift @@ -23,7 +23,17 @@ struct VideoView: View { var body: some View { GeometryReader { geometry in - ZStack(alignment: isRotated ? .center : .top) { + let aspectWidth = CGFloat(vm.currentQuality?.meta?.video?.value2.width ?? 16) + let aspectHeight = CGFloat(vm.currentQuality?.meta?.video?.value2.height ?? 9) + let aspectRatio = aspectWidth / max(aspectHeight, .leastNonzeroMagnitude) + let videoHeight = geometry.size.width / aspectRatio + ZStack(alignment: .top) { + if !isRotated { + Color.black + .frame(height: geometry.safeAreaInsets.top) + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + } if let stream = vm.stream { VideoPlayerWrapperView( videoURL: (stream.groups.first?.origins?.first?.url ?? ""), @@ -34,17 +44,18 @@ struct VideoView: View { isRotated: $isRotated, title: vm.video?.title ?? "", initialProgress: vm.video?.progress, + playerConfig: .constant(PlayerConfig()), observeProgress: { p in vm.uploadProgress(p) } ) - .aspectRatio(16/9, contentMode: .fit) + .frame(maxWidth: .infinity, alignment: .top) .zIndex(10000) } ScrollView { VStack { - Rectangle().aspectRatio(16/9, contentMode: .fit) - .frame(width: geometry.size.width, height: geometry.size.height/3.5) + Rectangle() + .frame(width: geometry.size.width, height: videoHeight) .opacity(0) HStack { @@ -82,10 +93,13 @@ struct VideoView: View { } isRotated = orientation == .landscapeLeft || orientation == .landscapeRight } - } + }.frame(height: isRotated ? 0 : nil) .toolbar(.hidden, for: .tabBar) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(isRotated ? Color.black : Color(.systemBackground)) } + .ignoresSafeArea(edges: .top) .onAppear(perform: { AppDelegate.orientationLock = .allButUpsideDown AppDelegate.rotateScreen(to: .portrait)