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)