diff --git a/FloatplaneAPIClient/Package.swift b/FloatplaneAPIClient/Package.swift index 9a1b6d4..e7c7945 100644 --- a/FloatplaneAPIClient/Package.swift +++ b/FloatplaneAPIClient/Package.swift @@ -7,6 +7,7 @@ let package = Package( platforms: [ .macOS(.v10_15), .tvOS(.v13), + .iOS(.v13) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. diff --git a/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json b/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c136eaf --- /dev/null +++ b/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,148 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/Contents.json b/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/ContentView.swift b/Shared/ContentView.swift new file mode 100644 index 0000000..9103843 --- /dev/null +++ b/Shared/ContentView.swift @@ -0,0 +1,88 @@ +// +// ContentView.swift +// Shared +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI +import FloatplaneAPIClient + +struct ContentView: View { + @ObservedObject var viewModel: AuthViewModel + + @State var showErrorMoreDetails = false + + @State var hasInitiallyLoaded = false + + @State var width = UIScreen.main.bounds.width + + @Environment(\.colorScheme) var colorScheme + + enum Notifications { + static let loggedOut = Notification.Name("com.jamesnl.Wasserflug-tvOSApp.loggedOut") + } + + var body: some View { + VStack { + ZStack { + if viewModel.isLoadingAuthStatus { + Image("wasserflug-logo") + .resizable() + .foregroundColor(colorScheme == .dark ? .white : .black) + .scaledToFit() + .frame(maxWidth: 250) + ProgressView() + } else if !viewModel.isLoggedIn { + LoginView(viewModel: viewModel) + } else { + RootTabView() + } + GeometryReader { proxy in + Color.clear.onChange(of: proxy.size.width) { newValue in + DispatchQueue.main.async { + self.width = newValue + } + }.onAppear { + DispatchQueue.main.async { + self.width = proxy.size.width + } + } + } + .hidden() + } + } + .environmentObject(viewModel.userInfo) + .environment(\.screenWidth, self.width) + .onAppear(perform: { + if !hasInitiallyLoaded { + hasInitiallyLoaded = true + viewModel.determineAuthenticationStatus() + } + }) + .onReceive(NotificationCenter.default.publisher(for: Notifications.loggedOut, object: nil), perform: { _ in + viewModel.determineAuthenticationStatus() + }) + .alert("Application Error", isPresented: $viewModel.showAuthenticationErrorAlert, presenting: viewModel.authenticationCheckError, actions: { _ in + Button("OK", action: {}) + Button("More Information", action: { + showErrorMoreDetails = true + }) + }, message: { error in + Text(""" +Logging in was successful, but an error was encountered while loading your user profile. Please submit a bug report with the app developer, *NOT* with Floatplane staff. + +\(error.localizedDescription) +""") + }) + .alert("Application Error", isPresented: $showErrorMoreDetails, presenting: viewModel.authenticationCheckError, actions: { _ in }, message: { error in + Text("\(String(describing: error))") + }) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView(viewModel: AuthViewModel(fpApiService: MockFPAPIService())) + } +} diff --git a/Shared/Launch Screen.storyboard b/Shared/Launch Screen.storyboard new file mode 100644 index 0000000..46e7b9e --- /dev/null +++ b/Shared/Launch Screen.storyboard @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Shared/SupplementalViews/BlogPostSelectionView.swift b/Shared/SupplementalViews/BlogPostSelectionView.swift new file mode 100644 index 0000000..1a6a6eb --- /dev/null +++ b/Shared/SupplementalViews/BlogPostSelectionView.swift @@ -0,0 +1,161 @@ +// +// BlogPostSelectionView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI +import FloatplaneAPIClient +import CachedAsyncImage + +struct BlogPostSelectionView: View { + enum ViewOrigin: Equatable { + case home(UserModel?) + case creator + } + + let blogPost: BlogPostModelV3 + let viewOrigin: ViewOrigin + @FetchRequest var watchProgresses: FetchedResults + + @Environment(\.fpApiService) var fpApiService + @Environment(\.managedObjectContext) private var viewContext + + @State var clickedOnVideo = false; + + var progress: CGFloat { + if let watchProgress = watchProgresses.first(where: { $0.videoId == blogPost.videoAttachments?.first }) { + let progress = watchProgress.progress + return progress >= 0.95 ? 1.0 : progress + } else { + return 0.0 + } + } + + private let relativeTimeConverter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + var body: some View { + let meta = blogPost.metadata + + VStack { + NavigationLink("Link to Video", isActive: $clickedOnVideo) { + BlogPostView(viewModel: BlogPostViewModel(fpApiService: fpApiService, id: blogPost.id), watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@ and videoId = %@", blogPost.id, blogPost.videoAttachments?.first ?? "")), shouldAutoPlay: true) + } + .hidden() + CachedAsyncImage(url: blogPost.thumbnail.pathUrlOrNil, content: { image in + ZStack(alignment: .bottomLeading) { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack { + HStack { + Spacer() + let duration: TimeInterval = meta.hasVideo ? meta.videoDuration : meta.hasAudio ? meta.audioDuration : 0.0 + if duration != 0 { + HStack { + Text("\(TimeInterval(duration).floatplaneTimestamp)") + .colorInvert() + } + .padding([.leading, .trailing], 5) + .background(FPColors.darkBlue) + .cornerRadius(10.0, corners: [.bottomLeft]) + } + } + Spacer() + } + GeometryReader { geometry in + Rectangle() + .fill(FPColors.blue) + .frame(width: geometry.size.width * progress) + } + .frame(height: 4) + } + .cornerRadius(10.0) + }, placeholder: { + ZStack { + ProgressView() + VStack { + HStack { + Spacer() + let duration: TimeInterval = meta.hasVideo ? meta.videoDuration : meta.hasAudio ? meta.audioDuration : 0.0 + if duration != 0 { + HStack { + Text("\(TimeInterval(duration).floatplaneTimestamp)") + } + .padding([.leading, .trailing], 5) + } + } + Spacer() + } + Rectangle() + .fill(.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(blogPost.thumbnail?.aspectRatio ?? 1.0, contentMode: .fit) + } + }) + HStack { + let profileImageSize: CGFloat = 35 + if case let .home(creatorOwner) = viewOrigin, + let profileImagePath = creatorOwner?.profileImage.path, + let profileImageUrl = URL(string: profileImagePath) { + CachedAsyncImage(url: profileImageUrl, content: { image in + image + .resizable() + .scaledToFit() + .frame(width: profileImageSize, height: profileImageSize) + .cornerRadius(profileImageSize / 2) + }, placeholder: { + ProgressView() + .frame(width: profileImageSize, height: profileImageSize) + }) + .padding([.all], 5) + } + VStack { + Text(verbatim: blogPost.title) + .font(.body) + .lineLimit(2) + } + Spacer() + } + HStack { + Text("\(meta.hasVideo ? "Video" : meta.hasAudio ? "Audio" : meta.hasGallery ? "Gallery" : "Picture")") + .font(.caption2) + .padding([.all], 7) + .foregroundColor(.white) + .background(.gray) + .cornerRadius(10) + + if case .home(_) = viewOrigin { + Text(verbatim: blogPost.creator.title) + .font(.system(size: 18, weight: .light)) + } + Spacer() + Text("\(relativeTimeConverter.localizedString(for: blogPost.releaseDate, relativeTo: Date()))") + .lineLimit(1) + } + }.onTapGesture { + self.clickedOnVideo.toggle() + } + } +} + +struct BlogPostSelectionView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + BlogPostSelectionView( + blogPost: MockData.blogPosts.blogPosts.first!, + viewOrigin: .home(MockData.creatorOwners.users.first!.user), + watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@", MockData.blogPosts.blogPosts.first!.id), animation: .default) + ) + .environment(\.fpApiService, MockFPAPIService()) + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + } + .padding(.all) + } +} diff --git a/Shared/SupplementalViews/VideoPlayerView.swift b/Shared/SupplementalViews/VideoPlayerView.swift new file mode 100644 index 0000000..19da23d --- /dev/null +++ b/Shared/SupplementalViews/VideoPlayerView.swift @@ -0,0 +1,165 @@ +// +// VideoPlayerView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 26/06/2022. +// + +import AVKit +import FloatplaneAPIClient +import SwiftUI +import Logging + +struct VideoPlayerView: View { + @ObservedObject var viewModel: VideoViewModel + + @Environment(\.screenWidth) var videoWidth: CGFloat + + let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) + .makeConnectable() + .autoconnect() + + let content: CdnDeliveryV2Response + let beginningWatchTime: Double + + // Get the aspect ratio of the video content + var aspectRatio: CGFloat { + let height = content.resource.data.qualityLevels?.first?.height ?? 9 + let width = content.resource.data.qualityLevels?.first?.width ?? 16 + let aspectRatio = CGFloat(CGFloat(height) / CGFloat(width)) + return aspectRatio + } + + var body: some View { + VideoPlayerViewWrapped(viewModel: viewModel, content: content, beginningWatchTime: beginningWatchTime) + .frame(width: self.videoWidth, height: aspectRatio * self.videoWidth) + } +} + +private var playerItemContext = 0 + +struct VideoPlayerViewWrapped: UIViewControllerRepresentable { + @AppStorage("DesiredQuality") var desiredQuality: String = "" + @ObservedObject var viewModel: VideoViewModel + @Environment(\.managedObjectContext) var managedObjectContext + + let content: CdnDeliveryV2Response + let beginningWatchTime: Double + + let logger: Logger = { + var logger = Wasserflug.logger + logger[metadataKey: "class"] = "\(Self.Type.self)" + return logger + }() + + func makeUIViewController(context: Context) -> ExtendedAVPlayerViewController { + let vc = ExtendedAVPlayerViewController() + vc.delegate = context.coordinator + let playerItem = viewModel.createAVPlayerItem(desiredQuality: desiredQuality) + if beginningWatchTime > 0.0, beginningWatchTime < 1.0 { + let totalSeconds = viewModel.videoAttachment.duration + let percentageOfTotal = beginningWatchTime + let seekToSeconds = totalSeconds * percentageOfTotal + let newCMTime = CMTime(seconds: seekToSeconds, preferredTimescale: 1) + playerItem.seek(to: newCMTime, completionHandler: nil) + } + vc.player = AVPlayer(playerItem: playerItem) + vc.player?.addObserver(context.coordinator, forKeyPath: #keyPath(AVPlayer.rate), context: nil) + vc.player?.play() // Needs to play when it first appears. + return vc + } + + func updateUIViewController(_ uiViewController: ExtendedAVPlayerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, AVPlayerViewControllerDelegate { + let parent: VideoPlayerViewWrapped + + init(_ parent: VideoPlayerViewWrapped) { + self.parent = parent + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + let extendedPlayerViewController = playerViewController as! ExtendedAVPlayerViewController + extendedPlayerViewController.isStatusBarHidden = false + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + let extendedPlayerViewController = playerViewController as! ExtendedAVPlayerViewController + extendedPlayerViewController.isStatusBarHidden = true + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let player = object as? AVPlayer else { + return; + } + if keyPath != #keyPath(AVPlayer.rate) { + return; + } + let fetchRequest = WatchProgress.fetchRequest() + let totalDurationSeconds = parent.viewModel.videoAttachment.duration + let blogPostId = parent.viewModel.contentPost.id + let videoId = parent.viewModel.videoAttachment.id + let progress: Double + if let lastPlayerItem = player.currentItem { + progress = Double(lastPlayerItem.currentTime().seconds) / totalDurationSeconds + } else { + progress = 0.0 + } + + fetchRequest.predicate = NSPredicate(format: "blogPostId = %@ and videoId = %@", blogPostId, videoId) + + parent.logger.notice("Attempting to record watch progress for a video.", metadata: [ + "blogPostId": "\(blogPostId)", + "videoId": "\(videoId)", + "totalDurationSeconds": "\(totalDurationSeconds)", + "progress": "\(progress)", + ]) + + if let fetchResult = (try? parent.managedObjectContext.fetch(fetchRequest))?.first { + parent.logger.info("Did find previous watchProgress. Will mutate with new progress.", metadata: [ + "previous": "\(String(reflecting: fetchResult))", + ]) + fetchResult.progress = progress + } else { + parent.logger.info("No previous watchProgress found. Will create new WatchProgress entity.") + let newWatchProgress = WatchProgress(context: parent.managedObjectContext) + newWatchProgress.blogPostId = blogPostId + newWatchProgress.videoId = videoId + newWatchProgress.progress = progress + } + + do { + try parent.managedObjectContext.save() + parent.logger.info("Successfully saved watch progress for \(blogPostId) \(videoId)") + } catch { + parent.logger.error("Error saving watch progress for for \(blogPostId) \(videoId): \(String(reflecting: error))") + // Otherwise, this has minimal impact on the user of this application. + // No need to further handle the error. + } + + if player.rate > 0.0 { + // Playback started + } else { + // Playback stopped + } + } + } +} + +class ExtendedAVPlayerViewController: AVPlayerViewController { + var isStatusBarHidden: Bool = false + + override var prefersStatusBarHidden: Bool { + return isStatusBarHidden + } +} + +// struct VideoPlayerView_Previews: PreviewProvider { +// static var previews: some View { +// VideoPlayerView() +// } +// } diff --git a/Shared/SupplementalViews/VisualEffectView.swift b/Shared/SupplementalViews/VisualEffectView.swift new file mode 100644 index 0000000..4f254a9 --- /dev/null +++ b/Shared/SupplementalViews/VisualEffectView.swift @@ -0,0 +1,14 @@ +// +// VisualEffectView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI + +struct VisualEffectView: UIViewRepresentable { + var effect: UIVisualEffect? + func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } + func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } +} diff --git a/Shared/Utilities/FPColors.swift b/Shared/Utilities/FPColors.swift new file mode 100644 index 0000000..767ba62 --- /dev/null +++ b/Shared/Utilities/FPColors.swift @@ -0,0 +1,14 @@ +// +// FPColors.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import Foundation +import SwiftUI + +enum FPColors { + static let blue = Color(.sRGB, red: 0, green: 175.0/256.0, blue: 236.0/256.0, opacity: 1.0) + static let darkBlue = Color(.sRGB, red: 0, green: 175.0/256.0, blue: 236.0/256.0, opacity: 1.0) +} diff --git a/Shared/Views/BlogPostView.swift b/Shared/Views/BlogPostView.swift new file mode 100644 index 0000000..7866f0b --- /dev/null +++ b/Shared/Views/BlogPostView.swift @@ -0,0 +1,164 @@ +// +// BlogPostView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI +import CachedAsyncImage + +struct BlogPostView: View { + @StateObject var viewModel: BlogPostViewModel + + @Environment(\.fpApiService) var fpApiService + @Environment(\.colorScheme) var colorScheme + + @EnvironmentObject var userInfo: UserInfo + + @FetchRequest var watchProgresses: FetchedResults + + var progress: CGFloat { + if let watchProgress = watchProgresses.first { + let progress = watchProgress.progress + return progress >= 0.95 ? 1.0 : progress + } else { + return 0.0 + } + } + + var formatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .medium + return formatter + } + + // Ignore, just make it compatible with the tvOS version + var shouldAutoPlay: Bool + + var body: some View { + NavigationView { + ZStack { + VStack { + switch viewModel.state { + case .idle: + Spacer() + ProgressView().onAppear(perform: { + viewModel.load(colorScheme: colorScheme) + }) + Spacer() + case .loading: + Spacer() + ProgressView() + Spacer() + case let .failed(error): + ErrorView(error: error) + case let .loaded(content): + ScrollView { + if let videoAttachments = content.videoAttachments, let firstVideo = videoAttachments.first { + VideoView(viewModel: VideoViewModel(fpApiService: fpApiService, videoAttachment: firstVideo, contentPost: content, description: viewModel.textAttributedString), beginningWatchTime: progress) + } else { + CachedAsyncImage(url: content.thumbnail.pathUrlOrNil) { image in + ZStack { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .blur(radius: 1.5) + VStack { + Spacer() + Image(systemName: "play.slash") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 42, height: 42) + .background(Circle().inset(by: -10).foregroundColor(.red)) + Spacer() + } + } + } placeholder: { + VStack { + Spacer() + ProgressView() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + HStack { + Text(content.title) + .font(.headline) + Spacer() + } + .padding(.horizontal) + HStack { + let profileImageSize: CGFloat = 35 + if let user = userInfo.creatorOwners[content.creator.owner], let path = user.profileImage.path { + CachedAsyncImage(url: URL(string: path), content: { image in + image + .resizable() + .scaledToFit() + .frame(width: profileImageSize, height: profileImageSize) + .cornerRadius(profileImageSize / 2) + }, placeholder: { + ProgressView() + .frame(width: profileImageSize, height: profileImageSize) + }) + .padding([.all], 5) + } + VStack { + HStack { + Text(verbatim: content.creator.title) + .font(.body) + .lineLimit(1) + Spacer() + } + HStack { + Text(content.releaseDate, formatter: self.formatter) + .font(.caption2) + Spacer() + } + } + HStack(alignment: .center) { + Button { + viewModel.like() + } label: { + let additional = viewModel.isLiked && viewModel.latestUserInteraction != nil ? 1 : 0 + Label("\(content.likes + additional)", systemImage: "hand.thumbsup") + } + .foregroundColor(viewModel.isLiked ? .green : colorScheme == .light ? .gray : .white) + Button { + viewModel.dislike() + } label: { + let additional = viewModel.isDisliked && viewModel.latestUserInteraction != nil ? 1 : 0 + Label("\(content.likes + additional)", systemImage: "hand.thumbsdown") + } + .foregroundColor(viewModel.isDisliked ? .red : colorScheme == .light ? .gray : .white) + } + Spacer() + } + .padding(.horizontal) + } + } + } + + // Blur navigation bar to recreate the translucent effect + VStack { + VisualEffectView(effect: UIBlurEffect(style: colorScheme == .light ? .light : .dark)) + .ignoresSafeArea(edges: .top) + .frame(height: 0) + + Spacer() + } + } + } + .navigationViewStyle(.stack) + } +} + +struct BlogPostView_Previews: PreviewProvider { + static var previews: some View { + BlogPostView(viewModel: BlogPostViewModel(fpApiService: MockFPAPIService(), id: ""), watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@", MockData.blogPosts.blogPosts.first!.id), animation: .default), shouldAutoPlay: false) + .environmentObject(MockData.userInfo) + } +} diff --git a/Shared/Views/CreatorContentView.swift b/Shared/Views/CreatorContentView.swift new file mode 100644 index 0000000..30cf02e --- /dev/null +++ b/Shared/Views/CreatorContentView.swift @@ -0,0 +1,229 @@ +// +// CreatorContentView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI +import FloatplaneAPIClient +import CachedAsyncImage + +struct CreatorContentView: View { + @EnvironmentObject var userInfo: UserInfo + @Environment(\.fpApiService) var fpApiService + @Environment(\.colorScheme) var colorScheme + + @StateObject var viewModel: CreatorContentViewModel + @StateObject var livestreamViewModel: LivestreamViewModel + + @State var isShowingSearch = false + @State var isShowingLive = false + + var gridColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 370)) + ] + + var body: some View { + VStack { + switch viewModel.state { + case .idle: + ProgressView().onAppear(perform: { + viewModel.load() + livestreamViewModel.load() + livestreamViewModel.startLoadingLiveStatus() + }) + case .loading: + ProgressView() + case let .failed(error): + ErrorView(error: error) + case let .loaded(content): + ScrollView { + GeometryReader { geometry in + ZStack { + CachedAsyncImage(url: viewModel.coverImagePath, content: { image in + if geometry.frame(in: .global).minY <= 0 { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + //.offset(y: geometry.frame(in: .global).minY/9) + .clipped() + } else { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height + geometry.frame(in: .global).minY) + .clipped() + .offset(y: -geometry.frame(in: .global).minY) + } + }, placeholder: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(viewModel.creator.cover?.aspectRatio ?? 1.0, contentMode: .fit) + }) + } + } + .frame(height: viewModel.coverImageWidth != nil && viewModel.coverImageHeight != nil ? ( UIScreen.main.bounds.size.width / viewModel.coverImageWidth!) * viewModel.coverImageHeight! : 150) + + // Logo and text + + let offset: CGFloat = 70 / 2 + + HStack { + let logoSize: CGFloat = 70 + + // isLive circle stroke width + let border: CGFloat = 3 + + // isLive circle gradient + + let gradient = Gradient(colors: [Color(red: 0.07, green: 0.76, blue: 0.91), Color(red: 0.77, green: 0.44, blue: 0.93), Color(red: 0.96, green: 0.31, blue: 0.35)]) + + let linearGradient = LinearGradient(gradient: gradient, startPoint: .topLeading, endPoint: .bottomTrailing) + + CachedAsyncImage(url: viewModel.creatorProfileImagePath, content: { image in + image + .resizable() + .frame(width: logoSize, height: logoSize) + .clipShape(Circle()) + + }, placeholder: { + ProgressView() + .frame(width: logoSize, height: logoSize) + }) + // isLive circle + .if(self.livestreamViewModel.isLive) { view in + view.overlay( + ZStack { + Circle() + .inset(by: -((border / 2) + 2)) + .stroke(linearGradient, lineWidth: border) + .frame(width: logoSize, height: logoSize) + .animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: true) + HStack { + Spacer() + VStack { + Spacer() + Image(systemName: "livephoto.play") + .foregroundStyle(linearGradient) + .offset(x: 5, y: 5) + } + } + } + ) + } + .sheet(isPresented: $isShowingLive, onDismiss: { + self.isShowingLive = false + }, content: { + LivestreamPlayerView(viewModel: self.livestreamViewModel) + .edgesIgnoringSafeArea(.all) + }) + .offset(y: -offset) + .onTapGesture { + if self.livestreamViewModel.isLive { + self.isShowingLive = true + } + } + + VStack { + Text(viewModel.creatorAboutHeader) + .fontWeight(.bold) + .font(.system(.headline)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Spacer() + } + Spacer() + } + .padding(.horizontal) + + HStack { + Text(viewModel.creatorAboutBody) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal) + .offset(y: -offset) + + + + // Videos + + LazyVGrid(columns: gridColumns, alignment: .center, spacing: 15) { + ForEach(content) { blogPost in + BlogPostSelectionView( + blogPost: blogPost, + viewOrigin: .creator, + watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@", blogPost.id), animation: .default) + ) + .onAppear(perform: { + viewModel.itemDidAppear(blogPost) + }) + } + } + .padding(.horizontal) + .offset(y: -offset) + } + .onDisappear { + viewModel.creatorContentDidDisappear() + }.onAppear { + viewModel.creatorContentDidAppearAgain() + livestreamViewModel.loadLiveStatus() + } + } + } + } + +} + +struct CreatorContentView_Previews: PreviewProvider { + @State static var selection = RootTabView.Selection.creator(MockData.creators[0].id) + static var previews: some View { + Group { + TabView(selection: $selection) { + Text("test").tabItem { + Text("Home") + } + CreatorContentView(viewModel: CreatorContentViewModel( + fpApiService: MockFPAPIService(), + creator: MockData.creators[0], + creatorOwner: MockData.creatorOwners.users[0].user + ), livestreamViewModel: LivestreamViewModel( + fpApiService: MockFPAPIService(), + creator: MockData.creators[0]) + ) + .tag(RootTabView.Selection.creator(MockData.creators[0].id)) + .tabItem { + Text(MockData.creators[0].title) + } + .environmentObject(MockData.userInfo) + Text("test").tabItem { + Text("Settings") + } + } + TabView(selection: $selection) { + Text("test").tabItem { + Text("Home") + } + CreatorContentView(viewModel: CreatorContentViewModel( + fpApiService: MockFPAPIService(), + creator: MockData.creators[0], + creatorOwner: MockData.creatorOwners.users[0].user + ), livestreamViewModel: LivestreamViewModel( + fpApiService: MockFPAPIService(), + creator: MockData.creators[0], mockIsLive: true) + ) + .tag(RootTabView.Selection.creator(MockData.creators[0].id)) + .tabItem { + Text(MockData.creators[0].title) + } + .environmentObject(MockData.userInfo) + Text("test").tabItem { + Text("Settings") + } + } + } + } +} + diff --git a/Shared/Views/CreatorSearchView.swift b/Shared/Views/CreatorSearchView.swift new file mode 100644 index 0000000..9862f7d --- /dev/null +++ b/Shared/Views/CreatorSearchView.swift @@ -0,0 +1,56 @@ +// +// CreatorSearchView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 23.06.22. +// + +import SwiftUI + +struct CreatorSearchView: View { + + @StateObject var viewModel: CreatorContentViewModel + + let creatorName: String + + private var gridColumns: [GridItem] { + return Array(repeating: GridItem(.flexible(minimum: 0, maximum: .infinity), alignment: .top), count: UIDevice.current.userInterfaceIdiom == .pad ? 2 : 1) + } + + var body: some View { + NavigationView { + ScrollView { + switch viewModel.state { + case .idle: + EmptyView() + case .loading: + ProgressView() + case let .failed(error): + ErrorView(error: error) + case let .loaded(content): + LazyVGrid(columns: gridColumns, spacing: 60) { + ForEach(content) { blogPost in + BlogPostSelectionView( + blogPost: blogPost, + viewOrigin: .creator, + watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@", blogPost.id), animation: .default) + ) + .onAppear(perform: { + viewModel.itemDidAppear(blogPost) + }) + } + } + .padding(40) + } + } + .searchable(text: $viewModel.searchText, prompt: "Search for Video") + .navigationTitle(self.creatorName) + } + } +} + +struct CreatorSearchView_Previews: PreviewProvider { + static var previews: some View { + CreatorSearchView(viewModel: CreatorContentViewModel(fpApiService: MockFPAPIService(), creator: MockData.creators.first!, creatorOwner: MockData.creatorOwners.users[0].user), creatorName: "Linus Tech Tips") + } +} diff --git a/Shared/Views/HomeView.swift b/Shared/Views/HomeView.swift new file mode 100644 index 0000000..3e59dbe --- /dev/null +++ b/Shared/Views/HomeView.swift @@ -0,0 +1,61 @@ +// +// HomeView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI + +struct HomeView: View { + @StateObject var viewModel: HomeViewModel + + @EnvironmentObject var userInfo: UserInfo + + var gridColumns: [GridItem] = [ + GridItem(.adaptive(minimum: 370)) + ] + + var body: some View { + NavigationView { + switch viewModel.state { + case .idle: + Color.clear.onAppear(perform: { + viewModel.load() + }) + case .loading: + ProgressView() + case let .failed(error): + ErrorView(error: error) + case let .loaded(response): + ScrollView { + LazyVGrid(columns: self.gridColumns, alignment: .center, spacing: 15) { + ForEach(response.blogPosts) { blogPost in + BlogPostSelectionView( + blogPost: blogPost, + viewOrigin: .home(userInfo.creatorOwners[blogPost.creator.owner.id]), + watchProgresses: FetchRequest(entity: WatchProgress.entity(), sortDescriptors: [], predicate: NSPredicate(format: "blogPostId = %@", blogPost.id), animation: .default) + ) + .onAppear(perform: { + viewModel.itemDidAppear(blogPost) + }) + } + } + .padding() + }.onDisappear { + viewModel.homeDidDisappear() + }.onAppear { + viewModel.homeDidAppearAgain() + } + } + } + .navigationViewStyle(.stack) + } +} + +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + HomeView(viewModel: HomeViewModel(userInfo: MockData.userInfo, fpApiService: MockFPAPIService())) + .environmentObject(MockData.userInfo) + } +} diff --git a/Shared/Views/LoginView.swift b/Shared/Views/LoginView.swift new file mode 100644 index 0000000..2eb1d75 --- /dev/null +++ b/Shared/Views/LoginView.swift @@ -0,0 +1,142 @@ +// +// LoginView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI + +struct LoginView: View { + @ObservedObject var viewModel: AuthViewModel + + @State var username: String = "" + @State var password: String = "" + @State var secondFactorCode: String = "" + + @Environment(\.colorScheme) var colorScheme + + private enum Field: Hashable { + case usernameField + case passwordField + case secondFactorField + case loginButton + } + @FocusState private var focusedField: Field? + + var body: some View { + NavigationView { + List { + // Logo + HStack { + Spacer() + Image("wasserflug-logo") + .resizable() + .foregroundColor(colorScheme == .dark ? .white : .black) + .scaledToFit() + .frame(maxWidth: 250) + Spacer() + } + .listRowBackground(EmptyView()) + .listRowSeparator(.hidden) + + // Login Form + Section(header: Text("Credentials")) { + + TextField("Username", text: $username) + .textInputAutocapitalization(.never) + .textContentType(.username) + .focused($focusedField, equals: .usernameField) + .disabled(viewModel.isAttemptingLogin || viewModel.needsSecondFactor || viewModel.isAttemptingSecondFactor) + + SecureField("Password", text: $password) + .textContentType(.password) + .focused($focusedField, equals: .passwordField) + .onSubmit { + withAnimation { + self.login() + } + } + .submitLabel(SubmitLabel.send) + .disabled(viewModel.isAttemptingLogin || viewModel.needsSecondFactor || viewModel.isAttemptingSecondFactor) + + if viewModel.needsSecondFactor { + TextField("2FA Code", text: $secondFactorCode) + .textContentType(.oneTimeCode) + .disabled(viewModel.isAttemptingSecondFactor) + } + + Button(action: { + withAnimation { + self.login() + } + }, label: { + HStack { + if viewModel.isAttemptingLogin || viewModel.isAttemptingSecondFactor { + ProgressView() + } + if viewModel.needsSecondFactor { + Text("Submit 2FA ✈️") + } else { + Text("Login ✈️") + } + } + }) + .focused($focusedField, equals: .loginButton) + .disabled(viewModel.isAttemptingLogin || viewModel.isAttemptingSecondFactor) + + } + } + .listStyle(.insetGrouped) + .navigationTitle("Login") + } + .alert("Login", isPresented: $viewModel.showIncorrectLoginAlert, actions: { }, message: { + if let error = viewModel.loginError { + Text(""" +There was an error while attempting to log in. Please submit a bug report with the app developer, *NOT* with Floatplane staff. + +\(error.localizedDescription) +""") + } else { + Text(""" +Username or password is incorrect. +If you have forgotten your password, please reset it via https://www.floatplane.com/reset-password +""") + } + }) + .navigationViewStyle(.stack) + } + + func login() { + if username.isEmpty { + focusedField = .usernameField + } else if password.isEmpty { + focusedField = .passwordField + } else { + if viewModel.needsSecondFactor, secondFactorCode.isEmpty { + focusedField = .secondFactorField + } else if viewModel.needsSecondFactor { + viewModel.attemptSecondFactor(secondFactorCode: secondFactorCode) { + viewModel.determineAuthenticationStatus() + print("Second Factor correct") + } + } else { + viewModel.attemptLogin(username: username, password: password) { + viewModel.determineAuthenticationStatus() + print("Logged in") + } + } + print("Test") + } + } +} + +struct LoginView_Previews: PreviewProvider { + + static var previews: some View { + Group { + LoginView(viewModel: AuthViewModel(fpApiService: MockFPAPIService())) + .previewInterfaceOrientation(.portrait) + } + } +} diff --git a/Shared/Views/SettingsView.swift b/Shared/Views/SettingsView.swift new file mode 100644 index 0000000..d9e298e --- /dev/null +++ b/Shared/Views/SettingsView.swift @@ -0,0 +1,59 @@ +// +// SettingsView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI +import CachedAsyncImage +import FloatplaneAPIClient + +struct SettingsView: View { + @EnvironmentObject var userInfo: UserInfo + + var body: some View { + NavigationView { + List { + if let userSelf = userInfo.userSelf, let imageUrl = URL(string: userSelf.profileImage.path) { + HStack { + Spacer() + HStack(spacing: 20) { + let pfpSize: CGFloat = 150 + CachedAsyncImage(url: imageUrl, content: { image in + image + .resizable() + .frame(width: pfpSize, height: pfpSize) + }, placeholder: { + ProgressView() + .frame(width: pfpSize, height: pfpSize) + }) + } + Spacer() + } + .listRowBackground(EmptyView()) + Section { + Text(verbatim: userSelf.username) + Button("Logout") { + FloatplaneAPIClientAPI.removeAuthenticationCookies() + NotificationCenter.default.post(name: ContentView.Notifications.loggedOut, object: nil) + } + } header: { + Text("User") + } + .listStyle(.insetGrouped) + + } + } + .navigationTitle("Settings") + } + .navigationViewStyle(.stack) + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView() + .environmentObject(MockData.userInfo) + } +} diff --git a/Shared/Views/VideoView.swift b/Shared/Views/VideoView.swift new file mode 100644 index 0000000..612171e --- /dev/null +++ b/Shared/Views/VideoView.swift @@ -0,0 +1,60 @@ +// +// VideoView.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 28/06/2022. +// + +import SwiftUI +import AVKit +import FloatplaneAPIClient +import CachedAsyncImage + +struct VideoView: View { + + @StateObject var viewModel: VideoViewModel + let beginningWatchTime: Double + + var body: some View { + switch viewModel.state { + case .idle: + Spacer() + ProgressView().onAppear(perform: { + viewModel.load() + }) + Spacer() + case .loading: + CachedAsyncImage(url: self.viewModel.contentPost.thumbnail.pathUrlOrNil) { image in + ZStack { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .blur(radius: 2) + VStack { + Spacer() + ProgressView() + Spacer() + } + } + } placeholder: { + VStack { + Spacer() + ProgressView() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + case let .failed(error): + ErrorView(error: error) + case let .loaded(content): + VideoPlayerView(viewModel: viewModel, content: content, beginningWatchTime: beginningWatchTime) + } + } +} + +struct VideoView_Previews: PreviewProvider { + static var previews: some View { + VideoView(viewModel: VideoViewModel(fpApiService: MockFPAPIService(), videoAttachment: MockData.getBlogPost.videoAttachments!.first!, contentPost: MockData.getBlogPost, description: "Test description"), beginningWatchTime: 0.0) + } +} diff --git a/Shared/Wasserflug.swift b/Shared/Wasserflug.swift new file mode 100644 index 0000000..2a0a196 --- /dev/null +++ b/Shared/Wasserflug.swift @@ -0,0 +1,82 @@ +// +// Wasserflug.swift +// Wasserflug-tvOS +// +// Created by Nils Bergmann on 22.06.22. +// + +import Foundation +import Vapor +import FloatplaneAPIClient + +struct Wasserflug { + static var logger: Logging.Logger { + var logger = Logging.Logger(label: Bundle.main.bundleIdentifier!) + #if DEBUG + // For debugging, log at a lower level to get more information. + logger.logLevel = .debug + #else + // For release mode, only log important items. + logger.logLevel = .notice + #endif + return logger + } + static var networkLogger: Logging.Logger { + var logger = Logging.Logger(label: Bundle.main.bundleIdentifier!) + logger.logLevel = .info + logger[metadataKey: "category"] = "network" + return logger + } + + let vaporApp = Vapor.Application(.production, .createNew) + let fpApiService: FPAPIService = DefaultFPAPIService() + let authViewModel: AuthViewModel + let persistenceController = PersistenceController.shared + + init() { + // Set custom user agent for network requests. This is particularly + // required in order to pass the login phase with bypassing the captcha. + Wasserflug.setHttp(header: "User-Agent", value: "Wasserflug tvOS App, CFNetwork") + + // Attempt to use any previous authentication cookies, so the user does + // not need to login on every app start. + FloatplaneAPIClientAPI.loadAuthenticationCookiesFromStorage() + + // Use FP's date format for JSON encoding/decoding. + let fpDateFormatter = DateFormatter() + fpDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + Configuration.contentConfiguration.use(encoder: JSONEncoder.custom(dates: .formatted(fpDateFormatter)), for: .json) + Configuration.contentConfiguration.use(decoder: JSONDecoder.custom(dates: .formatted(fpDateFormatter)), for: .json) + + // Bootstrap core logging. + LoggingSystem.bootstrap({ (label) -> LogHandler in + var loggingLogger = OSLoggingLogger(label: label, category: "Wasserflug") + loggingLogger.logLevel = .debug + return MultiplexLogHandler([ + loggingLogger, + ]) + }) + + // Bootstrap API/Network logging. + Configuration.apiClient = vaporApp.client + .logging(to: Wasserflug.networkLogger) + Configuration.apiWrapper = { clientRequest in + Wasserflug.networkLogger.info("Sending \(clientRequest.method) request to \(clientRequest.url)") + } + + // Create and store in @State the main view model. + authViewModel = AuthViewModel(fpApiService: fpApiService) + } + + private static func setHttp(header: String, value: String) { + FloatplaneAPIClientAPI.customHeaders.replaceOrAdd(name: header, value: value) + if var headers = URLSession.shared.configuration.httpAdditionalHeaders { + headers[header] = value + URLSession.shared.configuration.httpAdditionalHeaders = headers + } else { + URLSession.shared.configuration.httpAdditionalHeaders = [ + header: value, + ] + } + } +} diff --git a/Shared/WasserflugApp.swift b/Shared/WasserflugApp.swift new file mode 100644 index 0000000..a446c71 --- /dev/null +++ b/Shared/WasserflugApp.swift @@ -0,0 +1,21 @@ +// +// WasserflugApp.swift +// Shared +// +// Created by Nils Bergmann on 22.06.22. +// + +import SwiftUI + +@main +struct WasserflugApp: App { + let wasserflug = Wasserflug(); + + var body: some Scene { + WindowGroup { + ContentView(viewModel: wasserflug.authViewModel) + .environment(\.fpApiService, wasserflug.fpApiService) + .environment(\.managedObjectContext, wasserflug.persistenceController.container.viewContext) + } + } +} diff --git a/Wasserflug--iOS--Info.plist b/Wasserflug--iOS--Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Wasserflug--iOS--Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Wasserflug-tvOS.xcodeproj/project.pbxproj b/Wasserflug-tvOS.xcodeproj/project.pbxproj index 67ba292..5cae36f 100644 --- a/Wasserflug-tvOS.xcodeproj/project.pbxproj +++ b/Wasserflug-tvOS.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ E753379F27CB06BE0010E3D2 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378227CB06BE0010E3D2 /* VideoPlayerView.swift */; }; E75337A027CB06BE0010E3D2 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378327CB06BE0010E3D2 /* PlayButton.swift */; }; E75337A127CB06BE0010E3D2 /* PlayMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378427CB06BE0010E3D2 /* PlayMediaView.swift */; }; - E75337A227CB06BE0010E3D2 /* BlogPostSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378527CB06BE0010E3D2 /* BlogPostSelectionView.swift */; }; E75337A327CB06BE0010E3D2 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378627CB06BE0010E3D2 /* ErrorView.swift */; }; E75337A427CB06BE0010E3D2 /* BlogPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378827CB06BE0010E3D2 /* BlogPostViewModel.swift */; }; E75337A527CB06BE0010E3D2 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378927CB06BE0010E3D2 /* HomeViewModel.swift */; }; @@ -41,7 +40,6 @@ E75337AD27CB06BE0010E3D2 /* CreatorSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379227CB06BE0010E3D2 /* CreatorSearchView.swift */; }; E75337AE27CB06BE0010E3D2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379327CB06BE0010E3D2 /* SettingsView.swift */; }; E75337AF27CB06BE0010E3D2 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379427CB06BE0010E3D2 /* LoginView.swift */; }; - E75337B027CB06BE0010E3D2 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379527CB06BE0010E3D2 /* VideoView.swift */; }; E75337B127CB06BE0010E3D2 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379627CB06BE0010E3D2 /* HomeView.swift */; }; E75337B227CB06BE0010E3D2 /* PictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379727CB06BE0010E3D2 /* PictureView.swift */; }; E75337B327CB06BE0010E3D2 /* BlogPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379827CB06BE0010E3D2 /* BlogPostView.swift */; }; @@ -53,6 +51,91 @@ E75337BC27CB07900010E3D2 /* FloatplaneAPIClient in Frameworks */ = {isa = PBXBuildFile; productRef = E75337BB27CB07900010E3D2 /* FloatplaneAPIClient */; }; E75337BE27CB08990010E3D2 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75337BD27CB08990010E3D2 /* EnvironmentKeys.swift */; }; E7A447E92815E1C0009BCE2E /* SwiftUI+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A447E82815E1C0009BCE2E /* SwiftUI+Extensions.swift */; }; + FB09BD342868B74A00B52FED /* VideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378A27CB06BE0010E3D2 /* VideoViewModel.swift */; }; + FB09BD352868B74A00B52FED /* VideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378A27CB06BE0010E3D2 /* VideoViewModel.swift */; }; + FB09BD3C2868C5EC00B52FED /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB09BD3B2868C5EC00B52FED /* VideoPlayerView.swift */; }; + FB09BD3D2868C5EC00B52FED /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB09BD3B2868C5EC00B52FED /* VideoPlayerView.swift */; }; + FB18ED6F286880F100F53433 /* BlogPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB549286376FB006B4023 /* BlogPostView.swift */; }; + FB457F8128649753008A82AF /* CreatorSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB457F8028649753008A82AF /* CreatorSearchView.swift */; }; + FB457F8228649753008A82AF /* CreatorSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB457F8028649753008A82AF /* CreatorSearchView.swift */; }; + FB457F832864AB0D008A82AF /* LivestreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379A27CB06BE0010E3D2 /* LivestreamPlayerView.swift */; }; + FB457F842864AB0D008A82AF /* LivestreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379A27CB06BE0010E3D2 /* LivestreamPlayerView.swift */; }; + FBDDB4E5286339B2006B4023 /* WasserflugApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4D6286339B0006B4023 /* WasserflugApp.swift */; }; + FBDDB4E6286339B2006B4023 /* WasserflugApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4D6286339B0006B4023 /* WasserflugApp.swift */; }; + FBDDB4E7286339B2006B4023 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4D7286339B0006B4023 /* ContentView.swift */; }; + FBDDB4E8286339B2006B4023 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4D7286339B0006B4023 /* ContentView.swift */; }; + FBDDB4E9286339B2006B4023 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FBDDB4D8286339B2006B4023 /* Assets.xcassets */; }; + FBDDB4EA286339B2006B4023 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FBDDB4D8286339B2006B4023 /* Assets.xcassets */; }; + FBDDB4F2286339CD006B4023 /* FloatplaneAPIClient in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB4F1286339CD006B4023 /* FloatplaneAPIClient */; }; + FBDDB4F4286339D4006B4023 /* FloatplaneAPIClient in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB4F3286339D4006B4023 /* FloatplaneAPIClient */; }; + FBDDB4F728633A09006B4023 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4F628633A09006B4023 /* LoginView.swift */; }; + FBDDB4F828633A09006B4023 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB4F628633A09006B4023 /* LoginView.swift */; }; + FBDDB4FD28633A57006B4023 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378D27CB06BE0010E3D2 /* AuthViewModel.swift */; }; + FBDDB4FE28633A57006B4023 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378D27CB06BE0010E3D2 /* AuthViewModel.swift */; }; + FBDDB4FF28633A89006B4023 /* FPAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377427CB066E0010E3D2 /* FPAPIService.swift */; }; + FBDDB50028633A89006B4023 /* FPAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377427CB066E0010E3D2 /* FPAPIService.swift */; }; + FBDDB50228633A93006B4023 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB50128633A93006B4023 /* Vapor */; }; + FBDDB50428633A99006B4023 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB50328633A99006B4023 /* Vapor */; }; + FBDDB50528633C04006B4023 /* BaseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378B27CB06BE0010E3D2 /* BaseViewModel.swift */; }; + FBDDB50628633C05006B4023 /* BaseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378B27CB06BE0010E3D2 /* BaseViewModel.swift */; }; + FBDDB50728633C45006B4023 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378627CB06BE0010E3D2 /* ErrorView.swift */; }; + FBDDB50928633C47006B4023 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378627CB06BE0010E3D2 /* ErrorView.swift */; }; + FBDDB50E28633E7C006B4023 /* OSLoggingLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377627CB066E0010E3D2 /* OSLoggingLogger.swift */; }; + FBDDB51028633E7E006B4023 /* OSLoggingLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377627CB066E0010E3D2 /* OSLoggingLogger.swift */; }; + FBDDB51228633F36006B4023 /* Wasserflug.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB51128633F36006B4023 /* Wasserflug.swift */; }; + FBDDB51328633F36006B4023 /* Wasserflug.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB51128633F36006B4023 /* Wasserflug.swift */; }; + FBDDB51428633F36006B4023 /* Wasserflug.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB51128633F36006B4023 /* Wasserflug.swift */; }; + FBDDB51528633FCB006B4023 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB7827E66D7E00A9C88B /* Persistence.swift */; }; + FBDDB51628633FCB006B4023 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB7827E66D7E00A9C88B /* Persistence.swift */; }; + FBDDB51728633FEB006B4023 /* Wasserflug.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB7A27E66DB100A9C88B /* Wasserflug.xcdatamodeld */; }; + FBDDB51828633FEC006B4023 /* Wasserflug.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB7A27E66DB100A9C88B /* Wasserflug.xcdatamodeld */; }; + FBDDB5192863400A006B4023 /* Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377027CB066E0010E3D2 /* Authentication.swift */; }; + FBDDB51B2863400C006B4023 /* Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377027CB066E0010E3D2 /* Authentication.swift */; }; + FBDDB51C2863423F006B4023 /* FloatplaneAPIClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377127CB066E0010E3D2 /* FloatplaneAPIClient+Extensions.swift */; }; + FBDDB51E28634240006B4023 /* FloatplaneAPIClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753377127CB066E0010E3D2 /* FloatplaneAPIClient+Extensions.swift */; }; + FBDDB52028634577006B4023 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E753374027CAFAFF0010E3D2 /* Assets.xcassets */; }; + FBDDB52128634577006B4023 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E753374027CAFAFF0010E3D2 /* Assets.xcassets */; }; + FBDDB52228635446006B4023 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75337BD27CB08990010E3D2 /* EnvironmentKeys.swift */; }; + FBDDB52328635447006B4023 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75337BD27CB08990010E3D2 /* EnvironmentKeys.swift */; }; + FBDDB52428635CD0006B4023 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379D27CB06BE0010E3D2 /* RootTabView.swift */; }; + FBDDB52528635CD1006B4023 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379D27CB06BE0010E3D2 /* RootTabView.swift */; }; + FBDDB52728635CEE006B4023 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB52628635CEE006B4023 /* HomeView.swift */; }; + FBDDB52828635CEE006B4023 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB52628635CEE006B4023 /* HomeView.swift */; }; + FBDDB52928635D04006B4023 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378927CB06BE0010E3D2 /* HomeViewModel.swift */; }; + FBDDB52A28635D05006B4023 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378927CB06BE0010E3D2 /* HomeViewModel.swift */; }; + FBDDB52B28635D3C006B4023 /* ViewModelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378E27CB06BE0010E3D2 /* ViewModelState.swift */; }; + FBDDB52D28635D3F006B4023 /* ViewModelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378E27CB06BE0010E3D2 /* ViewModelState.swift */; }; + FBDDB52F28635DA7006B4023 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB52E28635DA7006B4023 /* SettingsView.swift */; }; + FBDDB53028635DA7006B4023 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB52E28635DA7006B4023 /* SettingsView.swift */; }; + FBDDB53528635E05006B4023 /* CreatorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB53428635E05006B4023 /* CreatorContentView.swift */; }; + FBDDB53628635E05006B4023 /* CreatorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB53428635E05006B4023 /* CreatorContentView.swift */; }; + FBDDB53728635E36006B4023 /* CreatorContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378F27CB06BE0010E3D2 /* CreatorContentViewModel.swift */; }; + FBDDB53928635E38006B4023 /* CreatorContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378F27CB06BE0010E3D2 /* CreatorContentViewModel.swift */; }; + FBDDB53A28635E3B006B4023 /* LivestreamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378C27CB06BE0010E3D2 /* LivestreamViewModel.swift */; }; + FBDDB53B28635E3B006B4023 /* LivestreamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378C27CB06BE0010E3D2 /* LivestreamViewModel.swift */; }; + FBDDB53D286361DD006B4023 /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB53C286361DD006B4023 /* CachedAsyncImage */; }; + FBDDB53F286361E1006B4023 /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = FBDDB53E286361E1006B4023 /* CachedAsyncImage */; }; + FBDDB54128636D1F006B4023 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FBDDB54028636D1F006B4023 /* Launch Screen.storyboard */; }; + FBDDB54228636D1F006B4023 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FBDDB54028636D1F006B4023 /* Launch Screen.storyboard */; }; + FBDDB54B286376FB006B4023 /* BlogPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB549286376FB006B4023 /* BlogPostView.swift */; }; + FBDDB54C28637718006B4023 /* BlogPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378827CB06BE0010E3D2 /* BlogPostViewModel.swift */; }; + FBDDB54D28637718006B4023 /* BlogPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378827CB06BE0010E3D2 /* BlogPostViewModel.swift */; }; + FBDDB54F286377B0006B4023 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB54E286377B0006B4023 /* VisualEffectView.swift */; }; + FBDDB550286377B0006B4023 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB54E286377B0006B4023 /* VisualEffectView.swift */; }; + FBDDB5532863780E006B4023 /* FPColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB5522863780E006B4023 /* FPColors.swift */; }; + FBDDB5542863780E006B4023 /* FPColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB5522863780E006B4023 /* FPColors.swift */; }; + FBDDB55528637814006B4023 /* FPColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB5522863780E006B4023 /* FPColors.swift */; }; + FBDDB55628637825006B4023 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB54E286377B0006B4023 /* VisualEffectView.swift */; }; + FBDDB557286378D0006B4023 /* Swift+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB3B27D3F89200A9C88B /* Swift+Extensions.swift */; }; + FBDDB559286378D2006B4023 /* Swift+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FDB3B27D3F89200A9C88B /* Swift+Extensions.swift */; }; + FBDDB55A286379EF006B4023 /* BlogPostSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753378527CB06BE0010E3D2 /* BlogPostSelectionView.swift */; }; + FBDDB55C28637A03006B4023 /* BlogPostSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB55B28637A03006B4023 /* BlogPostSelectionView.swift */; }; + FBDDB55D28637A03006B4023 /* BlogPostSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDDB55B28637A03006B4023 /* BlogPostSelectionView.swift */; }; + FBDDB55E2863807B006B4023 /* SwiftUI+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A447E82815E1C0009BCE2E /* SwiftUI+Extensions.swift */; }; + FBDDB5602863807C006B4023 /* SwiftUI+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A447E82815E1C0009BCE2E /* SwiftUI+Extensions.swift */; }; + FBF62588286B71C700283778 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E753379527CB06BE0010E3D2 /* VideoView.swift */; }; + FBF6258A286B71DB00283778 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBF62589286B71DB00283778 /* VideoView.swift */; }; + FBF6258B286B71DB00283778 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBF62589286B71DB00283778 /* VideoView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -122,6 +205,26 @@ E7A447E82815E1C0009BCE2E /* SwiftUI+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+Extensions.swift"; sourceTree = ""; }; E7CCF2D1285314B600506E51 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E7CCF2D2285315B400506E51 /* Wasserflug-tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Wasserflug-tvOS.entitlements"; sourceTree = ""; }; + FB09BD3B2868C5EC00B52FED /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + FB457F8028649753008A82AF /* CreatorSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorSearchView.swift; sourceTree = ""; }; + FBDDB4D6286339B0006B4023 /* WasserflugApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WasserflugApp.swift; sourceTree = ""; }; + FBDDB4D7286339B0006B4023 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + FBDDB4D8286339B2006B4023 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + FBDDB4DD286339B2006B4023 /* Wasserflug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Wasserflug.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FBDDB4E2286339B2006B4023 /* Wasserflug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Wasserflug.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FBDDB4E4286339B2006B4023 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; + FBDDB4F628633A09006B4023 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + FBDDB51128633F36006B4023 /* Wasserflug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wasserflug.swift; sourceTree = ""; }; + FBDDB51F2863454E006B4023 /* Wasserflug--iOS--Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Wasserflug--iOS--Info.plist"; sourceTree = ""; }; + FBDDB52628635CEE006B4023 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + FBDDB52E28635DA7006B4023 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + FBDDB53428635E05006B4023 /* CreatorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorContentView.swift; sourceTree = ""; }; + FBDDB54028636D1F006B4023 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; + FBDDB549286376FB006B4023 /* BlogPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogPostView.swift; sourceTree = ""; }; + FBDDB54E286377B0006B4023 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; + FBDDB5522863780E006B4023 /* FPColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPColors.swift; sourceTree = ""; }; + FBDDB55B28637A03006B4023 /* BlogPostSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogPostSelectionView.swift; sourceTree = ""; }; + FBF62589286B71DB00283778 /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -149,16 +252,39 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBDDB4DA286339B2006B4023 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB53D286361DD006B4023 /* CachedAsyncImage in Frameworks */, + FBDDB4F2286339CD006B4023 /* FloatplaneAPIClient in Frameworks */, + FBDDB50228633A93006B4023 /* Vapor in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FBDDB4DF286339B2006B4023 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB53F286361E1006B4023 /* CachedAsyncImage in Frameworks */, + FBDDB4F4286339D4006B4023 /* FloatplaneAPIClient in Frameworks */, + FBDDB50428633A99006B4023 /* Vapor in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ E753373027CAFAFE0010E3D2 = { isa = PBXGroup; children = ( + FBDDB51F2863454E006B4023 /* Wasserflug--iOS--Info.plist */, E753376E27CB043D0010E3D2 /* Packages */, E753373B27CAFAFE0010E3D2 /* Wasserflug-tvOS */, E753374C27CAFAFF0010E3D2 /* Wasserflug-tvOSTests */, E753375627CAFAFF0010E3D2 /* Wasserflug-tvOSUITests */, + FBDDB4D5286339B0006B4023 /* Shared */, + FBDDB4E3286339B2006B4023 /* macOS */, E753373A27CAFAFE0010E3D2 /* Products */, E75337BA27CB07900010E3D2 /* Frameworks */, ); @@ -173,6 +299,8 @@ E753373927CAFAFE0010E3D2 /* Wasserflug.app */, E753374927CAFAFF0010E3D2 /* Wasserflug-tvOSTests.xctest */, E753375327CAFAFF0010E3D2 /* Wasserflug-tvOSUITests.xctest */, + FBDDB4DD286339B2006B4023 /* Wasserflug.app */, + FBDDB4E2286339B2006B4023 /* Wasserflug.app */, ); name = Products; sourceTree = ""; @@ -298,6 +426,69 @@ name = Frameworks; sourceTree = ""; }; + FBDDB4D5286339B0006B4023 /* Shared */ = { + isa = PBXGroup; + children = ( + FBDDB551286377FC006B4023 /* Utilities */, + FBDDB54328637583006B4023 /* SupplementalViews */, + FBDDB4F928633A19006B4023 /* ViewModels */, + FBDDB4F5286339E6006B4023 /* Views */, + FBDDB4D6286339B0006B4023 /* WasserflugApp.swift */, + FBDDB4D7286339B0006B4023 /* ContentView.swift */, + FBDDB4D8286339B2006B4023 /* Assets.xcassets */, + FBDDB51128633F36006B4023 /* Wasserflug.swift */, + FBDDB54028636D1F006B4023 /* Launch Screen.storyboard */, + ); + path = Shared; + sourceTree = ""; + }; + FBDDB4E3286339B2006B4023 /* macOS */ = { + isa = PBXGroup; + children = ( + FBDDB4E4286339B2006B4023 /* macOS.entitlements */, + ); + path = macOS; + sourceTree = ""; + }; + FBDDB4F5286339E6006B4023 /* Views */ = { + isa = PBXGroup; + children = ( + FBDDB4F628633A09006B4023 /* LoginView.swift */, + FBDDB52628635CEE006B4023 /* HomeView.swift */, + FBDDB52E28635DA7006B4023 /* SettingsView.swift */, + FBDDB53428635E05006B4023 /* CreatorContentView.swift */, + FBDDB549286376FB006B4023 /* BlogPostView.swift */, + FB457F8028649753008A82AF /* CreatorSearchView.swift */, + FBF62589286B71DB00283778 /* VideoView.swift */, + ); + path = Views; + sourceTree = ""; + }; + FBDDB4F928633A19006B4023 /* ViewModels */ = { + isa = PBXGroup; + children = ( + ); + path = ViewModels; + sourceTree = ""; + }; + FBDDB54328637583006B4023 /* SupplementalViews */ = { + isa = PBXGroup; + children = ( + FBDDB54E286377B0006B4023 /* VisualEffectView.swift */, + FBDDB55B28637A03006B4023 /* BlogPostSelectionView.swift */, + FB09BD3B2868C5EC00B52FED /* VideoPlayerView.swift */, + ); + path = SupplementalViews; + sourceTree = ""; + }; + FBDDB551286377FC006B4023 /* Utilities */ = { + isa = PBXGroup; + children = ( + FBDDB5522863780E006B4023 /* FPColors.swift */, + ); + path = Utilities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -359,6 +550,50 @@ productReference = E753375327CAFAFF0010E3D2 /* Wasserflug-tvOSUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + FBDDB4DC286339B2006B4023 /* Wasserflug (iOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = FBDDB4EF286339B2006B4023 /* Build configuration list for PBXNativeTarget "Wasserflug (iOS)" */; + buildPhases = ( + FBDDB4D9286339B2006B4023 /* Sources */, + FBDDB4DA286339B2006B4023 /* Frameworks */, + FBDDB4DB286339B2006B4023 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Wasserflug (iOS)"; + packageProductDependencies = ( + FBDDB4F1286339CD006B4023 /* FloatplaneAPIClient */, + FBDDB50128633A93006B4023 /* Vapor */, + FBDDB53C286361DD006B4023 /* CachedAsyncImage */, + ); + productName = "Wasserflug (iOS)"; + productReference = FBDDB4DD286339B2006B4023 /* Wasserflug.app */; + productType = "com.apple.product-type.application"; + }; + FBDDB4E1286339B2006B4023 /* Wasserflug (macOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = FBDDB4F0286339B2006B4023 /* Build configuration list for PBXNativeTarget "Wasserflug (macOS)" */; + buildPhases = ( + FBDDB4DE286339B2006B4023 /* Sources */, + FBDDB4DF286339B2006B4023 /* Frameworks */, + FBDDB4E0286339B2006B4023 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Wasserflug (macOS)"; + packageProductDependencies = ( + FBDDB4F3286339D4006B4023 /* FloatplaneAPIClient */, + FBDDB50328633A99006B4023 /* Vapor */, + FBDDB53E286361E1006B4023 /* CachedAsyncImage */, + ); + productName = "Wasserflug (macOS)"; + productReference = FBDDB4E2286339B2006B4023 /* Wasserflug.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -366,7 +601,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 1330; LastUpgradeCheck = 1320; TargetAttributes = { E753373827CAFAFE0010E3D2 = { @@ -380,6 +615,12 @@ CreatedOnToolsVersion = 13.2.1; TestTargetID = E753373827CAFAFE0010E3D2; }; + FBDDB4DC286339B2006B4023 = { + CreatedOnToolsVersion = 13.3.1; + }; + FBDDB4E1286339B2006B4023 = { + CreatedOnToolsVersion = 13.3.1; + }; }; }; buildConfigurationList = E753373427CAFAFE0010E3D2 /* Build configuration list for PBXProject "Wasserflug-tvOS" */; @@ -402,6 +643,8 @@ E753373827CAFAFE0010E3D2 /* Wasserflug-tvOS */, E753374827CAFAFF0010E3D2 /* Wasserflug-tvOSTests */, E753375227CAFAFF0010E3D2 /* Wasserflug-tvOSUITests */, + FBDDB4DC286339B2006B4023 /* Wasserflug (iOS) */, + FBDDB4E1286339B2006B4023 /* Wasserflug (macOS) */, ); }; /* End PBXProject section */ @@ -430,6 +673,26 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBDDB4DB286339B2006B4023 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB54128636D1F006B4023 /* Launch Screen.storyboard in Resources */, + FBDDB52028634577006B4023 /* Assets.xcassets in Resources */, + FBDDB4E9286339B2006B4023 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FBDDB4E0286339B2006B4023 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB54228636D1F006B4023 /* Launch Screen.storyboard in Resources */, + FBDDB52128634577006B4023 /* Assets.xcassets in Resources */, + FBDDB4EA286339B2006B4023 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -442,6 +705,7 @@ E75337B827CB06BE0010E3D2 /* RootTabView.swift in Sources */, E75337A127CB06BE0010E3D2 /* PlayMediaView.swift in Sources */, E70448E22803A9110045A1AE /* WaveformView.swift in Sources */, + FBDDB51228633F36006B4023 /* Wasserflug.swift in Sources */, E75337B227CB06BE0010E3D2 /* PictureView.swift in Sources */, E75337B327CB06BE0010E3D2 /* BlogPostView.swift in Sources */, E75337B127CB06BE0010E3D2 /* HomeView.swift in Sources */, @@ -449,27 +713,29 @@ E7A447E92815E1C0009BCE2E /* SwiftUI+Extensions.swift in Sources */, E75337AB27CB06BE0010E3D2 /* CreatorContentViewModel.swift in Sources */, E75337AF27CB06BE0010E3D2 /* LoginView.swift in Sources */, + FBDDB55628637825006B4023 /* VisualEffectView.swift in Sources */, E753377B27CB066E0010E3D2 /* FPAPIService.swift in Sources */, E753377827CB066E0010E3D2 /* FloatplaneAPIClient+Extensions.swift in Sources */, E75337A627CB06BE0010E3D2 /* VideoViewModel.swift in Sources */, E75337BE27CB08990010E3D2 /* EnvironmentKeys.swift in Sources */, E75337B727CB06BE0010E3D2 /* ContentView.swift in Sources */, + FBDDB55A286379EF006B4023 /* BlogPostSelectionView.swift in Sources */, E753377727CB066E0010E3D2 /* Authentication.swift in Sources */, E753377F27CB06800010E3D2 /* Wasserflug_tvOSApp.swift in Sources */, E75337A927CB06BE0010E3D2 /* AuthViewModel.swift in Sources */, E75337AA27CB06BE0010E3D2 /* ViewModelState.swift in Sources */, - E75337B027CB06BE0010E3D2 /* VideoView.swift in Sources */, E753379E27CB06BE0010E3D2 /* BlogPostContentView.swift in Sources */, E75337A027CB06BE0010E3D2 /* PlayButton.swift in Sources */, + FBDDB55528637814006B4023 /* FPColors.swift in Sources */, E75337A527CB06BE0010E3D2 /* HomeViewModel.swift in Sources */, E75337AD27CB06BE0010E3D2 /* CreatorSearchView.swift in Sources */, + FBF62588286B71C700283778 /* VideoView.swift in Sources */, E71FDB7927E66D7E00A9C88B /* Persistence.swift in Sources */, E753377D27CB066E0010E3D2 /* OSLoggingLogger.swift in Sources */, E75337A727CB06BE0010E3D2 /* BaseViewModel.swift in Sources */, E75337B527CB06BE0010E3D2 /* LivestreamPlayerView.swift in Sources */, E75337A827CB06BE0010E3D2 /* LivestreamViewModel.swift in Sources */, E753379F27CB06BE0010E3D2 /* VideoPlayerView.swift in Sources */, - E75337A227CB06BE0010E3D2 /* BlogPostSelectionView.swift in Sources */, E75337B427CB06BE0010E3D2 /* CreatorContentView.swift in Sources */, E75337A327CB06BE0010E3D2 /* ErrorView.swift in Sources */, E75337AE27CB06BE0010E3D2 /* SettingsView.swift in Sources */, @@ -495,6 +761,88 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBDDB4D9286339B2006B4023 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB52928635D04006B4023 /* HomeViewModel.swift in Sources */, + FBDDB557286378D0006B4023 /* Swift+Extensions.swift in Sources */, + FBDDB53528635E05006B4023 /* CreatorContentView.swift in Sources */, + FB18ED6F286880F100F53433 /* BlogPostView.swift in Sources */, + FBDDB55E2863807B006B4023 /* SwiftUI+Extensions.swift in Sources */, + FB457F8128649753008A82AF /* CreatorSearchView.swift in Sources */, + FBDDB4FF28633A89006B4023 /* FPAPIService.swift in Sources */, + FBDDB5532863780E006B4023 /* FPColors.swift in Sources */, + FBDDB52428635CD0006B4023 /* RootTabView.swift in Sources */, + FBDDB52228635446006B4023 /* EnvironmentKeys.swift in Sources */, + FBDDB51C2863423F006B4023 /* FloatplaneAPIClient+Extensions.swift in Sources */, + FBDDB53A28635E3B006B4023 /* LivestreamViewModel.swift in Sources */, + FBDDB4FD28633A57006B4023 /* AuthViewModel.swift in Sources */, + FBDDB4F728633A09006B4023 /* LoginView.swift in Sources */, + FBDDB54C28637718006B4023 /* BlogPostViewModel.swift in Sources */, + FBDDB55C28637A03006B4023 /* BlogPostSelectionView.swift in Sources */, + FBDDB4E7286339B2006B4023 /* ContentView.swift in Sources */, + FBDDB52B28635D3C006B4023 /* ViewModelState.swift in Sources */, + FBDDB50728633C45006B4023 /* ErrorView.swift in Sources */, + FBDDB50E28633E7C006B4023 /* OSLoggingLogger.swift in Sources */, + FBDDB51328633F36006B4023 /* Wasserflug.swift in Sources */, + FB09BD342868B74A00B52FED /* VideoViewModel.swift in Sources */, + FBF6258A286B71DB00283778 /* VideoView.swift in Sources */, + FBDDB4E5286339B2006B4023 /* WasserflugApp.swift in Sources */, + FBDDB50528633C04006B4023 /* BaseViewModel.swift in Sources */, + FBDDB54F286377B0006B4023 /* VisualEffectView.swift in Sources */, + FBDDB51528633FCB006B4023 /* Persistence.swift in Sources */, + FBDDB53728635E36006B4023 /* CreatorContentViewModel.swift in Sources */, + FBDDB52728635CEE006B4023 /* HomeView.swift in Sources */, + FBDDB51728633FEB006B4023 /* Wasserflug.xcdatamodeld in Sources */, + FBDDB5192863400A006B4023 /* Authentication.swift in Sources */, + FB457F832864AB0D008A82AF /* LivestreamPlayerView.swift in Sources */, + FBDDB52F28635DA7006B4023 /* SettingsView.swift in Sources */, + FB09BD3C2868C5EC00B52FED /* VideoPlayerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FBDDB4DE286339B2006B4023 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBDDB52A28635D05006B4023 /* HomeViewModel.swift in Sources */, + FBDDB559286378D2006B4023 /* Swift+Extensions.swift in Sources */, + FBDDB54B286376FB006B4023 /* BlogPostView.swift in Sources */, + FBDDB53628635E05006B4023 /* CreatorContentView.swift in Sources */, + FBDDB5602863807C006B4023 /* SwiftUI+Extensions.swift in Sources */, + FB457F8228649753008A82AF /* CreatorSearchView.swift in Sources */, + FBDDB50028633A89006B4023 /* FPAPIService.swift in Sources */, + FBDDB5542863780E006B4023 /* FPColors.swift in Sources */, + FBDDB52528635CD1006B4023 /* RootTabView.swift in Sources */, + FBDDB52328635447006B4023 /* EnvironmentKeys.swift in Sources */, + FBDDB51E28634240006B4023 /* FloatplaneAPIClient+Extensions.swift in Sources */, + FBDDB53B28635E3B006B4023 /* LivestreamViewModel.swift in Sources */, + FBDDB4FE28633A57006B4023 /* AuthViewModel.swift in Sources */, + FBDDB4F828633A09006B4023 /* LoginView.swift in Sources */, + FBDDB54D28637718006B4023 /* BlogPostViewModel.swift in Sources */, + FBDDB55D28637A03006B4023 /* BlogPostSelectionView.swift in Sources */, + FBDDB4E8286339B2006B4023 /* ContentView.swift in Sources */, + FBDDB52D28635D3F006B4023 /* ViewModelState.swift in Sources */, + FBDDB50928633C47006B4023 /* ErrorView.swift in Sources */, + FBDDB51028633E7E006B4023 /* OSLoggingLogger.swift in Sources */, + FBDDB51428633F36006B4023 /* Wasserflug.swift in Sources */, + FB09BD352868B74A00B52FED /* VideoViewModel.swift in Sources */, + FBF6258B286B71DB00283778 /* VideoView.swift in Sources */, + FBDDB4E6286339B2006B4023 /* WasserflugApp.swift in Sources */, + FBDDB50628633C05006B4023 /* BaseViewModel.swift in Sources */, + FBDDB550286377B0006B4023 /* VisualEffectView.swift in Sources */, + FBDDB51628633FCB006B4023 /* Persistence.swift in Sources */, + FBDDB53928635E38006B4023 /* CreatorContentViewModel.swift in Sources */, + FBDDB52828635CEE006B4023 /* HomeView.swift in Sources */, + FBDDB51828633FEC006B4023 /* Wasserflug.xcdatamodeld in Sources */, + FBDDB51B2863400C006B4023 /* Authentication.swift in Sources */, + FB457F842864AB0D008A82AF /* LivestreamPlayerView.swift in Sources */, + FBDDB53028635DA7006B4023 /* SettingsView.swift in Sources */, + FB09BD3D2868C5EC00B52FED /* VideoPlayerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -765,6 +1113,125 @@ }; name = Release; }; + FBDDB4EB286339B2006B4023 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "Wasserflug-tvOS/Assets.xcassets Wasserflug-tvOS/Preview\\ Content/Preview\\ Assets.xcassets"; + DEVELOPMENT_TEAM = P88CDRP8CR; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Wasserflug--iOS--Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = "Launch Screen"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jamesnl.Wasserflug; + PRODUCT_NAME = Wasserflug; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FBDDB4EC286339B2006B4023 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "Wasserflug-tvOS/Assets.xcassets Wasserflug-tvOS/Preview\\ Content/Preview\\ Assets.xcassets"; + DEVELOPMENT_TEAM = P88CDRP8CR; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Wasserflug--iOS--Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = "Launch Screen"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jamesnl.Wasserflug; + PRODUCT_NAME = Wasserflug; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + FBDDB4ED286339B2006B4023 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "Wasserflug-tvOS/Assets.xcassets Wasserflug-tvOS/Preview\\ Content/Preview\\ Assets.xcassets"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jamesnl.Wasserflug; + PRODUCT_NAME = Wasserflug; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + FBDDB4EE286339B2006B4023 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "Wasserflug-tvOS/Assets.xcassets Wasserflug-tvOS/Preview\\ Content/Preview\\ Assets.xcassets"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jamesnl.Wasserflug; + PRODUCT_NAME = Wasserflug; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -804,6 +1271,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FBDDB4EF286339B2006B4023 /* Build configuration list for PBXNativeTarget "Wasserflug (iOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FBDDB4EB286339B2006B4023 /* Debug */, + FBDDB4EC286339B2006B4023 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FBDDB4F0286339B2006B4023 /* Build configuration list for PBXNativeTarget "Wasserflug (macOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FBDDB4ED286339B2006B4023 /* Debug */, + FBDDB4EE286339B2006B4023 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -820,7 +1305,7 @@ repositoryURL = "https://github.com/vapor/vapor"; requirement = { kind = exactVersion; - version = 4.55.0; + version = 4.62.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -840,6 +1325,34 @@ isa = XCSwiftPackageProductDependency; productName = FloatplaneAPIClient; }; + FBDDB4F1286339CD006B4023 /* FloatplaneAPIClient */ = { + isa = XCSwiftPackageProductDependency; + productName = FloatplaneAPIClient; + }; + FBDDB4F3286339D4006B4023 /* FloatplaneAPIClient */ = { + isa = XCSwiftPackageProductDependency; + productName = FloatplaneAPIClient; + }; + FBDDB50128633A93006B4023 /* Vapor */ = { + isa = XCSwiftPackageProductDependency; + package = E753376927CB01FD0010E3D2 /* XCRemoteSwiftPackageReference "vapor" */; + productName = Vapor; + }; + FBDDB50328633A99006B4023 /* Vapor */ = { + isa = XCSwiftPackageProductDependency; + package = E753376927CB01FD0010E3D2 /* XCRemoteSwiftPackageReference "vapor" */; + productName = Vapor; + }; + FBDDB53C286361DD006B4023 /* CachedAsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = E753376627CAFF550010E3D2 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; + }; + FBDDB53E286361E1006B4023 /* CachedAsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = E753376627CAFF550010E3D2 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Wasserflug-tvOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Wasserflug-tvOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3c29deb..2429a66 100644 --- a/Wasserflug-tvOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Wasserflug-tvOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/swift-server/async-http-client.git", "state": { "branch": null, - "revision": "7a4dfe026f6ee0f8ad741b58df74c60af296365d", - "version": "1.9.0" + "revision": "794dc9d42720af97cedd395e8cd2add9173ffd9a", + "version": "1.11.1" } }, { @@ -51,8 +51,17 @@ "repositoryURL": "https://github.com/vapor/routing-kit.git", "state": { "branch": null, - "revision": "5603b81ceb744b8318feab1e60943704977a866b", - "version": "4.3.1" + "revision": "9e181d685a3dec1eef1fc6dacf606af364f86d68", + "version": "4.5.0" + } + }, + { + "package": "swift-algorithms", + "repositoryURL": "https://github.com/apple/swift-algorithms.git", + "state": { + "branch": null, + "revision": "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version": "1.0.0" } }, { @@ -114,8 +123,8 @@ "repositoryURL": "https://github.com/apple/swift-nio-http2.git", "state": { "branch": null, - "revision": "000ca94f9de92c95b9ac85d44600b7b0fe25a3e5", - "version": "1.19.2" + "revision": "108ac15087ea9b79abb6f6742699cf31de0e8772", + "version": "1.22.0" } }, { @@ -136,6 +145,15 @@ "version": "1.11.4" } }, + { + "package": "swift-numerics", + "repositoryURL": "https://github.com/apple/swift-numerics", + "state": { + "branch": null, + "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", + "version": "1.0.2" + } + }, { "package": "swiftui-cached-async-image", "repositoryURL": "https://github.com/lorenzofiamingo/swiftui-cached-async-image", @@ -150,8 +168,8 @@ "repositoryURL": "https://github.com/vapor/vapor", "state": { "branch": null, - "revision": "18e9419cae5049e43ca1e8002ca3cf0449f2c8ed", - "version": "4.55.0" + "revision": "12e2e7460ab912b65fb7a0fe47e4f638a7d5e642", + "version": "4.62.0" } }, { diff --git a/Wasserflug-tvOS.xcodeproj/xcshareddata/xcschemes/Wasserflug-tvOS.xcscheme b/Wasserflug-tvOS.xcodeproj/xcshareddata/xcschemes/Wasserflug-tvOS.xcscheme index e300488..40e3262 100644 --- a/Wasserflug-tvOS.xcodeproj/xcshareddata/xcschemes/Wasserflug-tvOS.xcscheme +++ b/Wasserflug-tvOS.xcodeproj/xcshareddata/xcschemes/Wasserflug-tvOS.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + some View { if let namespace = namespace { @@ -10,3 +11,37 @@ extension View { } } } +#endif + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape( RoundedCorner(radius: radius, corners: corners) ) + } +} + +struct RoundedCorner: Shape { + + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +// https://www.avanderlee.com/swiftui/conditional-view-modifier/ +extension View { + /// Applies the given transform if the given condition evaluates to `true`. + /// - Parameters: + /// - condition: The condition to evaluate. + /// - transform: The transform to apply to the source `View`. + /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. + @ViewBuilder func `if`(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View { + if condition() { + transform(self) + } else { + self + } + } +} diff --git a/Wasserflug-tvOS/ViewModels/BaseViewModel.swift b/Wasserflug-tvOS/ViewModels/BaseViewModel.swift index a18f12c..11b46a3 100644 --- a/Wasserflug-tvOS/ViewModels/BaseViewModel.swift +++ b/Wasserflug-tvOS/ViewModels/BaseViewModel.swift @@ -4,7 +4,7 @@ import Vapor class BaseViewModel { lazy var logger: Logger = { - var logger = Wasserflug_tvOSApp.logger + var logger = Wasserflug.logger logger[metadataKey: "class"] = "\(Self.Type.self)" return logger }() diff --git a/Wasserflug-tvOS/ViewModels/CreatorContentViewModel.swift b/Wasserflug-tvOS/ViewModels/CreatorContentViewModel.swift index 3d0cc39..119bc92 100644 --- a/Wasserflug-tvOS/ViewModels/CreatorContentViewModel.swift +++ b/Wasserflug-tvOS/ViewModels/CreatorContentViewModel.swift @@ -29,7 +29,31 @@ class CreatorContentViewModel: BaseViewModel, ObservableObject { return nil } } - + + var coverImageWidth: CGFloat? { + if let cover = creator.cover { + return CGFloat(cover.width) + } else { + return nil + } + } + + var coverImageHeight: CGFloat? { + if let cover = creator.cover { + return CGFloat(cover.height) + } else { + return nil + } + } + + var coverRatio: CGFloat? { + if let cover = creator.cover { + return CGFloat(cover.aspectRatio) + } else { + return nil + } + } + var creatorProfileImagePath: URL? { URL(string: creatorOwner.profileImage.path) } diff --git a/Wasserflug-tvOS/ViewModels/LivestreamViewModel.swift b/Wasserflug-tvOS/ViewModels/LivestreamViewModel.swift index 56953fc..d93634a 100644 --- a/Wasserflug-tvOS/ViewModels/LivestreamViewModel.swift +++ b/Wasserflug-tvOS/ViewModels/LivestreamViewModel.swift @@ -7,12 +7,22 @@ import Vapor class LivestreamViewModel: BaseViewModel, ObservableObject { @Published var state: ViewModelState = .idle - @Published var isLive: Bool = false + @Published var fetchedIsLive: Bool = false @Published var isLoadingLiveStatus: Bool = false + + var isLive: Bool { + if mockIsLive { + return true + } else { + return fetchedIsLive + } + } private let fpApiService: FPAPIService let creator: CreatorModelV2 + private var mockIsLive: Bool = false + var avMetadataItems: [AVMetadataItem] { return [ metadataItem(identifier: .commonIdentifierTitle, value: creator.liveStream?.title ?? "Livestream"), @@ -29,6 +39,13 @@ class LivestreamViewModel: BaseViewModel, ObservableObject { self.creator = creator } + /// For preview mocking + init(fpApiService: FPAPIService, creator: CreatorModelV2, mockIsLive: Bool) { + self.fpApiService = fpApiService + self.creator = creator + self.mockIsLive = mockIsLive + } + func load() { state = .loading @@ -96,19 +113,19 @@ class LivestreamViewModel: BaseViewModel, ObservableObject { switch result { case let .success(clientResponse): if clientResponse.status == .ok { - self.isLive = true + self.fetchedIsLive = true self.logger.debug("Livestream is live", metadata: [ "id": "\(self.creator.id)", ]) } else { - self.isLive = false + self.fetchedIsLive = false self.logger.debug("Livestream is not live", metadata: [ "id": "\(self.creator.id)", ]) } case let .failure(error): self.logger.warning("Encountered unexpected error when loading livestream status. Error: \(String(reflecting: error))") - self.isLive = false + self.fetchedIsLive = false } } }) diff --git a/Wasserflug-tvOS/ViewModels/VideoViewModel.swift b/Wasserflug-tvOS/ViewModels/VideoViewModel.swift index 240be7a..03f44f7 100644 --- a/Wasserflug-tvOS/ViewModels/VideoViewModel.swift +++ b/Wasserflug-tvOS/ViewModels/VideoViewModel.swift @@ -83,7 +83,10 @@ class VideoViewModel: BaseViewModel, ObservableObject { ]) let baseCdn = response.cdn let pathTemplate = response.resource.uri! - let screen = UIScreen.main + var screenWidth = UIScreen.main.bounds.width + if screenWidth < UIScreen.main.bounds.height { + screenWidth = UIScreen.main.bounds.height + } let levels = response.resource.data.qualityLevels? .filter({ qualityLevel in // Filter out resolutions larger than the device's screen resolution. @@ -93,9 +96,9 @@ class VideoViewModel: BaseViewModel, ObservableObject { // will allow for actually getting 1080p on 1080p screens, with only a little bit of downsampling. // But, it wouldn't make sense to show 4k on a 1080p screen. Waste of bandwidth, and the // hardware may not like the massive downsampling. - let result = CGFloat(qualityLevel.width) <= (screen.bounds.width * 1.15) + let result = CGFloat(qualityLevel.width) <= (screenWidth * 1.15) if !result { - self.logger.warning("Ignoring quality level \(String(describing: qualityLevel)) due to larger-than-screen width of \(screen.bounds.width)") + self.logger.warning("Ignoring quality level \(String(describing: qualityLevel)) due to larger-than-screen width of \(screenWidth)") } return result }) diff --git a/Wasserflug-tvOS/Views/ContentView.swift b/Wasserflug-tvOS/Views/ContentView.swift index 0e5572e..b2a59a2 100644 --- a/Wasserflug-tvOS/Views/ContentView.swift +++ b/Wasserflug-tvOS/Views/ContentView.swift @@ -3,11 +3,6 @@ import FloatplaneAPIClient import NIO import Network -enum FPColors { - static let blue = Color(.sRGB, red: 0, green: 175.0/256.0, blue: 236.0/256.0, opacity: 1.0) - static let darkBlue = Color(.sRGB, red: 0, green: 175.0/256.0, blue: 236.0/256.0, opacity: 1.0) -} - struct ContentView: View { @ObservedObject var viewModel: AuthViewModel @@ -128,9 +123,3 @@ struct ContentView_Previews: PreviewProvider { ContentView(viewModel: AuthViewModel(fpApiService: MockFPAPIService())) } } - -struct VisualEffectView: UIViewRepresentable { - var effect: UIVisualEffect? - func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } - func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } -} diff --git a/Wasserflug-tvOS/Views/LoginView.swift b/Wasserflug-tvOS/Views/LoginView.swift index 5669fef..5092055 100644 --- a/Wasserflug-tvOS/Views/LoginView.swift +++ b/Wasserflug-tvOS/Views/LoginView.swift @@ -28,8 +28,7 @@ struct LoginView: View { VStack { Text("Login to Floatplane") .bold() - Spacer() - + Spacer() TextField("Username", text: $username) .textContentType(.username) .focused($focusedField, equals: .usernameField) diff --git a/Wasserflug-tvOS/Views/RootTabView.swift b/Wasserflug-tvOS/Views/RootTabView.swift index 3095757..4c46142 100644 --- a/Wasserflug-tvOS/Views/RootTabView.swift +++ b/Wasserflug-tvOS/Views/RootTabView.swift @@ -18,7 +18,11 @@ struct RootTabView: View { HomeView(viewModel: HomeViewModel(userInfo: userInfo, fpApiService: fpApiService)) .tag(Selection.home) .tabItem { + #if os(tvOS) Text("Home") + #else + Label("Home", systemImage: "house") + #endif } ForEach(userInfo.userSubscriptions) { sub in let creator = userInfo.creators[sub.creator]! @@ -26,13 +30,21 @@ struct RootTabView: View { CreatorContentView(viewModel: CreatorContentViewModel(fpApiService: fpApiService, creator: creator, creatorOwner: creatorOwner), livestreamViewModel: LivestreamViewModel(fpApiService: fpApiService, creator: creator)) .tag(Selection.creator(creator.id)) .tabItem { - Text(creator.title) + #if os(tvOS) + Text(creator.title) + #else + Label(creator.title, systemImage: "person.crop.rectangle") + #endif } } SettingsView() .tag(Selection.settings) .tabItem { - Text("Settings") + #if os(tvOS) + Text("Settings") + #else + Label("Settings", systemImage: "gear") + #endif } } } diff --git a/Wasserflug-tvOS/Views/SettingsView.swift b/Wasserflug-tvOS/Views/SettingsView.swift index fe1a163..3eeca19 100644 --- a/Wasserflug-tvOS/Views/SettingsView.swift +++ b/Wasserflug-tvOS/Views/SettingsView.swift @@ -14,7 +14,7 @@ struct SettingsView: View { @State var showResetViewHistoryFailure = false let logger: Logger = { - var logger = Wasserflug_tvOSApp.logger + var logger = Wasserflug.logger logger[metadataKey: "class"] = "\(Self.Type.self)" return logger }() diff --git a/Wasserflug-tvOS/Wasserflug_tvOSApp.swift b/Wasserflug-tvOS/Wasserflug_tvOSApp.swift index 39f33aa..0181254 100644 --- a/Wasserflug-tvOS/Wasserflug_tvOSApp.swift +++ b/Wasserflug-tvOS/Wasserflug_tvOSApp.swift @@ -6,81 +6,13 @@ import Logging @main struct Wasserflug_tvOSApp: App { - static var logger: Logging.Logger { - var logger = Logging.Logger(label: Bundle.main.bundleIdentifier!) - #if DEBUG - // For debugging, log at a lower level to get more information. - logger.logLevel = .debug - #else - // For release mode, only log important items. - logger.logLevel = .notice - #endif - return logger - } - static var networkLogger: Logging.Logger { - var logger = Logging.Logger(label: Bundle.main.bundleIdentifier!) - logger.logLevel = .info - logger[metadataKey: "category"] = "network" - return logger - } - - let vaporApp = Vapor.Application(.production, .createNew) - let fpApiService: FPAPIService = DefaultFPAPIService() - let authViewModel: AuthViewModel - let persistenceController = PersistenceController.shared - - init() { - // Set custom user agent for network requests. This is particularly - // required in order to pass the login phase with bypassing the captcha. - Wasserflug_tvOSApp.setHttp(header: "User-Agent", value: "Wasserflug tvOS App, CFNetwork") - - // Attempt to use any previous authentication cookies, so the user does - // not need to login on every app start. - FloatplaneAPIClientAPI.loadAuthenticationCookiesFromStorage() - - // Use FP's date format for JSON encoding/decoding. - let fpDateFormatter = DateFormatter() - fpDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - Configuration.contentConfiguration.use(encoder: JSONEncoder.custom(dates: .formatted(fpDateFormatter)), for: .json) - Configuration.contentConfiguration.use(decoder: JSONDecoder.custom(dates: .formatted(fpDateFormatter)), for: .json) - - // Bootstrap core logging. - LoggingSystem.bootstrap({ (label) -> LogHandler in - var loggingLogger = OSLoggingLogger(label: label, category: "Wasserflug") - loggingLogger.logLevel = .debug - return MultiplexLogHandler([ - loggingLogger, - ]) - }) - - // Bootstrap API/Network logging. - Configuration.apiClient = vaporApp.client - .logging(to: Wasserflug_tvOSApp.networkLogger) - Configuration.apiWrapper = { clientRequest in - Wasserflug_tvOSApp.networkLogger.info("Sending \(clientRequest.method) request to \(clientRequest.url)") - } - - // Create and store in @State the main view model. - authViewModel = AuthViewModel(fpApiService: fpApiService) - } + let wasserflug = Wasserflug(); var body: some Scene { WindowGroup { - ContentView(viewModel: authViewModel) - .environment(\.fpApiService, fpApiService) - .environment(\.managedObjectContext, persistenceController.container.viewContext) - } - } - - private static func setHttp(header: String, value: String) { - FloatplaneAPIClientAPI.customHeaders.replaceOrAdd(name: header, value: value) - if var headers = URLSession.shared.configuration.httpAdditionalHeaders { - headers[header] = value - URLSession.shared.configuration.httpAdditionalHeaders = headers - } else { - URLSession.shared.configuration.httpAdditionalHeaders = [ - header: value, - ] + ContentView(viewModel: wasserflug.authViewModel) + .environment(\.fpApiService, wasserflug.fpApiService) + .environment(\.managedObjectContext, wasserflug.persistenceController.container.viewContext) } } } diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements new file mode 100644 index 0000000..f2ef3ae --- /dev/null +++ b/macOS/macOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + +