diff --git a/app/xcode/Sources/VCamUI/Extensions/CGSize+.swift b/app/xcode/Sources/VCamUI/Extensions/CGSize+.swift new file mode 100644 index 0000000..8a90079 --- /dev/null +++ b/app/xcode/Sources/VCamUI/Extensions/CGSize+.swift @@ -0,0 +1,18 @@ +// +// CGSize+.swift +// +// +// Created by Tatsuya Tanaka on 2022/04/29. +// + +import CoreGraphics + +public extension CGSize { + mutating func scaleToFit(size: CGSize) { + if width > height { + width = height * size.width / size.height + } else { + height = width * size.height / size.width + } + } +} diff --git a/app/xcode/Sources/VCamUI/RootContentView.swift b/app/xcode/Sources/VCamUI/RootView.swift similarity index 52% rename from app/xcode/Sources/VCamUI/RootContentView.swift rename to app/xcode/Sources/VCamUI/RootView.swift index 0ab7b99..24f586b 100644 --- a/app/xcode/Sources/VCamUI/RootContentView.swift +++ b/app/xcode/Sources/VCamUI/RootView.swift @@ -1,6 +1,6 @@ // -// RootContentView.swift -// +// RootView.swift +// // // Created by Tatsuya Tanaka on 2022/06/25. // @@ -8,28 +8,47 @@ import SwiftUI import VCamBridge -public struct RootContentView: View { - public init(menuBottomView: MenuBottomView, unityView: NSView, interactable: ExternalStateBinding = .init(.interactable)) { - self.menuBottomView = menuBottomView +public struct RootView: View { + public init(unityView: NSView, interactable: ExternalStateBinding = .init(.interactable)) { + self.unityView = unityView + self.interactable = interactable + } + + let unityView: NSView + + @StateObject var state = VCamUIState() + + @AppStorage(key: .locale) var locale + private var interactable: ExternalStateBinding + + public var body: some View { + RootViewContent(unityView: unityView, interactable: interactable) + .background(.regularMaterial) + .environmentObject(state) + .environment(\.locale, locale.isEmpty ? .current : Locale(identifier: locale)) + } +} + +private struct RootViewContent: View { + init(unityView: NSView, interactable: ExternalStateBinding) { self.unityView = unityView self._interactable = interactable } - let menuBottomView: MenuBottomView let unityView: NSView + @ExternalStateBinding(.interactable) private var interactable + @UniReload private var reload: Void - public var body: some View { + var body: some View { if interactable { HStack(spacing: 0) { - VCamMenu( - bottomView: menuBottomView.frame(height: 280) - ) - .onTapGesture { - unityView.window?.makeFirstResponder(nil) - NotificationCenter.default.post(name: .unfocusObject, object: nil) - } - .disabled(!interactable) + VCamMenu() + .onTapGesture { + unityView.window?.makeFirstResponder(nil) + NotificationCenter.default.post(name: .unfocusObject, object: nil) + } + .disabled(!interactable) VSplitView { HStack(alignment: .bottom, spacing: 0) { @@ -57,7 +76,7 @@ public struct RootContentView: View { } } -struct UnityView: View, Equatable { +private struct UnityView: View, Equatable { let unityView: NSView var body: some View { @@ -70,24 +89,22 @@ struct UnityView: View, Equatable { static func == (lhs: Self, rhs: Self) -> Bool { true } -} -private struct UnityContainerView: NSViewRepresentable { - let unityView: NSView - func makeNSView(context: Context) -> some NSView { - unityView - } + private struct UnityContainerView: NSViewRepresentable { + let unityView: NSView + + func makeNSView(context: Context) -> some NSView { + unityView + } - func updateNSView(_ nsView: NSViewType, context: Context) { + func updateNSView(_ nsView: NSViewType, context: Context) { + } } } -struct RootContentView_Previews: PreviewProvider { - static var previews: some View { - RootContentView( - menuBottomView: Color.blue, - unityView: NSView(), - interactable: .constant(true) - ) - } +#Preview { + RootView( + unityView: NSView(), + interactable: .constant(true) + ) } diff --git a/app/xcode/Sources/VCamUI/SceneObjectManager.swift b/app/xcode/Sources/VCamUI/SceneObjectManager.swift index a544c30..f7345ab 100644 --- a/app/xcode/Sources/VCamUI/SceneObjectManager.swift +++ b/app/xcode/Sources/VCamUI/SceneObjectManager.swift @@ -177,6 +177,10 @@ public final class SceneObjectManager: ObservableObject { uniUpdateScene() } + public func didChangeObjects() { + self.objects = objects + } + public func dispose() { objects = objects.filter { $0.id == SceneObject.avatarID } RenderTextureManager.shared.removeAll() diff --git a/app/xcode/Sources/VCamUI/VCamMainObjectListView.swift b/app/xcode/Sources/VCamUI/VCamMainObjectListView.swift new file mode 100644 index 0000000..f6a9870 --- /dev/null +++ b/app/xcode/Sources/VCamUI/VCamMainObjectListView.swift @@ -0,0 +1,433 @@ +// +// VCamMainObjectListView.swift +// +// +// Created by Tatsuya Tanaka on 2022/04/29. +// + +import SwiftUI +import VCamUIFoundation +import VCamEntity +import VCamBridge + +public struct VCamMainObjectListView: View { + public init() {} + + @ExternalStateBinding(.objectSelected) private var objectSelected + @ObservedObject private var objectManager = SceneObjectManager.shared + + @State private var editingId: Int32? + + var selectedIdBinding: Binding { + $objectSelected.map(get: { $0 == -1 ? nil : $0 }, set: { $0 ?? -1 }) + } + + public var body: some View { + let selectedId = selectedIdBinding.wrappedValue + GroupBox { + List(selection: selectedIdBinding) { + ForEach($objectManager.objects) { $object in + TextFieldListRow( + id: object.id, + text: .init(value: object.name, set: { + // Workaround for this bug: https://www.reddit.com/r/SwiftUI/comments/11gujra/swiftui_bug_deleting_an_object_while_the/ + // Do not use `$object.name` now + object.name = $0 + }), + editingId: $editingId, + selectedId: selectedId + ) { + uniUpdateScene() + } + .opacity(object.isHidden ? 0.5 : 1) + .modifier(EditSceneObjectViewModifier(object: object)) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .trailing) { + if object.isLocked { + Image(systemName: "lock") + } + } + .tag(object.id) + } + .onMove { source, destination in + objectManager.move(fromOffsets: source, toOffset: destination) + } + } + .scrollContentBackground(.hidden) + + VCamMainObjectListBottomBar(selectedId: selectedId) + } label: { + Text(L10n.object.key, bundle: .localize) + } + .onReceive(NotificationCenter.default.publisher(for: .unfocusObject)) { _ in + selectedIdBinding.wrappedValue = nil + } + } +} + +private struct VCamMainObjectListAddButton: View { + @ObservedObject private var pasteboard = WindowManager.shared.system.pasteboardObserver + + var body: some View { + let objectManager = SceneObjectManager.shared + Menu { + if let url = pasteboard.imageURL { + Button { + objectManager.addImage(url: url) + } label: { + Image(systemName: "photo") + Text(L10n.clipboard.key, bundle: .localize) + } + } + Button { + if let url = FileUtility.openFile(type: .image) { + objectManager.addImage(url: url) + } + } label: { + Image(systemName: "photo") + Text(L10n.image.key, bundle: .localize) + } + Button { + showScreenRecorderPreferenceView { recorder in + guard let config = recorder.captureConfig, let screenId = config.id else { return } + let id = RenderTextureManager.shared.add(recorder) + objectManager.add(.init(id: id, type: .screen(.init(id: screenId, captureType: config.captureType.type, textureSize: recorder.size, crop: recorder.cropRect, filter: nil)), isHidden: false, isLocked: false)) + } + } label: { + Image(systemName: "display") + Text(L10n.screen.key, bundle: .localize) + } + Button { + CaptureDeviceRenderer.selectDevice { drawer in + let id = RenderTextureManager.shared.add(drawer) + objectManager.add(.init(id: id, type: .videoCapture(.init(id: drawer.id, textureSize: drawer.size, crop: drawer.cropRect, filter: nil)), isHidden: false, isLocked: false)) + } + } label: { + Image(systemName: "camera") + Text(L10n.videoCaptureDevice.key, bundle: .localize) + } + Button { + WebRenderer.showPreferencesForAdding() + } label: { + Image(systemName: "network") + Text(L10n.web.key, bundle: .localize) + } + + Divider() + + Button { + objectManager.add(.init(type: .wind(), isHidden: false, isLocked: false)) + } label: { + Image(systemName: "wind") + Text(L10n.wind.key, bundle: .localize) + } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .contentShape(Rectangle()) + .fixedSize() + } +} + +private struct VCamMainObjectListBottomBar: View { + let selectedId: Int32? + + @ObservedObject private var objectManager = SceneObjectManager.shared + + var body: some View { + HStack { + VCamMainObjectListAddButton() + + Group { + let isLocked = selectedId.flatMap(objectManager.objects.find(byId:))?.isLocked ?? false + if selectedId == SceneObject.avatarID { + Button { + UniBridge.shared.resetCamera() + } label: { + Image(systemName: "arrow.uturn.backward") + } + .disabled(isLocked) + } else { + Button { + if let selectedId = selectedId { + objectManager.remove(byId: selectedId) + } + } label: { + Image(systemName: "minus") + .background(Color.clear) + .frame(height: 14) + } + .contentShape(Rectangle()) + .disabled(isLocked) + } + + Button { + if let selectedId = selectedId { + objectManager.move(byId: selectedId, up: false) + } + } label: { + Image(systemName: "chevron.up") + } + Button { + if let selectedId = selectedId { + objectManager.move(byId: selectedId, up: true) + } + } label: { + Image(systemName: "chevron.down") + } + } + .disabled(selectedId == nil) + .buttonStyle(.borderless) + + Spacer() + } + } +} + +private struct EditSceneObjectButton: View { + var key = L10n.edit.key + let isLocked: Bool + let action: () -> Void + var body: some View { + Button { + action() + } label: { + Image(systemName: "pencil") + Text(key, bundle: .localize) + } + .disabled(isLocked) + } +} + +private struct FilterSceneObjectButton: View { + let object: SceneObject + let configuration: ImageFilterConfiguration? + let filter: (ImageFilter) -> Void + var body: some View { + Button { + let image = RenderTextureManager.shared.drawer(id: object.id)?.croppedSnapshot() ?? .init() + showImageFilterView(image: image, configuration: configuration) { filter in + RenderTextureManager.shared.drawer(id: object.id)?.filter = filter + self.filter(filter) + } + } label: { + Image(systemName: "wand.and.stars") + Text(L10n.filter.key, bundle: .localize) + } + .disabled(object.isLocked) + } +} + +private struct DeleteSceneObjectButton: View { + let object: SceneObject + + var body: some View { + Button(role: .destructive) { + SceneObjectManager.shared.remove(byId: object.id) + } label: { + Image(systemName: "trash") + Text(L10n.remove.key, bundle: .localize) + } + .disabled(object.isLocked) + } +} + +private struct HideSceneObjectButton: View { + let object: SceneObject + + var body: some View { + Button(role: .destructive) { + var newObject = object + newObject.isHidden.toggle() + SceneObjectManager.shared.update(newObject) + } label: { + Image(systemName: "eye") + .symbolVariant(object.isHidden ? .none : .slash) + Text(object.isHidden ? L10n.show.key : L10n.hide.key, bundle: .localize) + } + } +} + +private struct LockSceneObjectButton: View { + let object: SceneObject + + var body: some View { + Button(role: .destructive) { + var newObject = object + newObject.isLocked.toggle() + SceneObjectManager.shared.update(newObject) + } label: { + Image(systemName: "lock") + .symbolVariant(object.isLocked ? .slash : .none) + Text(object.isLocked ? L10n.unlock.key : L10n.lock.key, bundle: .localize) + } + } +} + +private struct EditSceneObjectViewModifier: ViewModifier { + let object: SceneObject + + @ObservedObject private var __ = SceneObjectManager.shared // observe changes + + func body(content: Content) -> some View { + let renderTextureManager = RenderTextureManager.shared + switch object.type { + case .avatar: + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + EditSceneObjectButton(isLocked: object.isLocked) { + UniBridge.shared.editAvatar() + } + Divider() + Button { + UniBridge.shared.resetCamera() + } label: { + Image(systemName: "arrow.uturn.backward") + Text(L10n.moveInitialPosition.key, bundle: .localize) + } + } + case let .image(image): + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + Button { + if image.size.width > image.size.height { + image.size = .init(width: image.size.width / image.size.height, height: 1) + } else { + image.size = .init(width: 1, height: image.size.height / image.size.width) + } + image.offset = .zero + var newObject = object + newObject.isLocked = true + SceneObjectManager.shared.update(newObject) + SceneObjectManager.shared.moveToBack(id: object.id) + } label: { + Image(systemName: "person.and.background.dotted") + Text(L10n.setAsBackground.key, bundle: .localize) + } + Divider() + EditSceneObjectButton(isLocked: object.isLocked) { + if let url = FileUtility.openFile(type: .image) { + image.url = url + image.size = .zero + if let imageRenderer = renderTextureManager.drawer(id: object.id) as? VCamUI.ImageRenderer { + let renderer = ImageRenderer(imageURL: url, filter: imageRenderer.filter) + renderTextureManager.set(renderer, id: object.id) + } + SceneObjectManager.shared.update(object) + } + } + FilterSceneObjectButton(object: object, configuration: image.filter?.configuration) { filter in + image.filter = filter + applyFilter() + } + Divider() + DeleteSceneObjectButton(object: object) + } + case let .screen(screen): + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + EditSceneObjectButton(isLocked: object.isLocked) { + showScreenRecorderPreferenceView { recorder in + guard let screenId = recorder.captureConfig?.id else { return } + renderTextureManager.set(recorder, id: object.id) + screen.id = screenId + screen.textureSize = recorder.size + screen.region.size.scaleToFit(size: screen.textureSize) + screen.crop = recorder.cropRect + recorder.filter = screen.filter + SceneObjectManager.shared.update(object) + } + } + FilterSceneObjectButton(object: object, configuration: screen.filter?.configuration) { filter in + screen.filter = filter + applyFilter() + } + Divider() + DeleteSceneObjectButton(object: object) + } + case let .videoCapture(videoCapture): + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + EditSceneObjectButton(isLocked: object.isLocked) { + CaptureDeviceRenderer.selectDevice { drawer in + renderTextureManager.set(drawer, id: object.id) + videoCapture.id = drawer.id + videoCapture.textureSize = drawer.size + videoCapture.region.size.scaleToFit(size: videoCapture.textureSize) + videoCapture.crop = drawer.cropRect + drawer.filter = videoCapture.filter + SceneObjectManager.shared.update(object) + } + } + FilterSceneObjectButton(object: object, configuration: videoCapture.filter?.configuration) { filter in + videoCapture.filter = filter + applyFilter() + } + Divider() + DeleteSceneObjectButton(object: object) + } + case let .web(web): + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + EditSceneObjectButton(isLocked: object.isLocked) { + WebRenderer.showPreferences(url: web.url?.absoluteString, bookmarkData: web.path, width: Int(web.textureSize.width), height: Int(web.textureSize.height), fps: web.fps, css: web.css, js: web.js) { renderer in + renderTextureManager.set(renderer, id: object.id) + web.textureSize = renderer.size + web.region.size.scaleToFit(size: web.textureSize) + web.crop = renderer.cropRect + renderer.filter = web.filter + SceneObjectManager.shared.update(object) + } + } + Button { + guard let renderer = RenderTextureManager.shared.drawer(id: object.id) as? WebRenderer else { return } + renderer.showWindow() + } label: { + Image(systemName: "network") + Text(L10n.interact.key, bundle: .localize) + } + FilterSceneObjectButton(object: object, configuration: web.filter?.configuration) { filter in + web.filter = filter + applyFilter() + } + Divider() + DeleteSceneObjectButton(object: object) + } + case let .wind(wind): + content + .contextMenu { + HideSceneObjectButton(object: object) + LockSceneObjectButton(object: object) + Divider() + EditSceneObjectButton(key: L10n.changeWindDirection.key, isLocked: object.isLocked) { + wind.direction = SceneObject.Wind.random.direction + SceneObjectManager.shared.update(object) + } + Divider() + DeleteSceneObjectButton(object: object) + } + } + } + + private func applyFilter() { + uniUpdateScene() + SceneObjectManager.shared.didChangeObjects() // Reflect the state when resetting the filter + } +} diff --git a/app/xcode/Sources/VCamUI/VCamMenu.swift b/app/xcode/Sources/VCamUI/VCamMenu.swift index 2cc36eb..c66171a 100644 --- a/app/xcode/Sources/VCamUI/VCamMenu.swift +++ b/app/xcode/Sources/VCamUI/VCamMenu.swift @@ -38,13 +38,7 @@ public enum VCamMenuItem: Identifiable, CaseIterable { } } -public struct VCamMenu: View { - public init(bottomView: BottomView) { - self.bottomView = bottomView - } - - let bottomView: BottomView - +public struct VCamMenu: View { @EnvironmentObject var state: VCamUIState public var body: some View { @@ -62,7 +56,8 @@ public struct VCamMenu: View { .buttonStyle(VCamMenuButtonStyle(isSelected: item == state.currentMenu)) } Spacer() - bottomView + MenuBottomView() + .frame(height: 280) } .padding(8) .frame(width: 140) @@ -70,6 +65,53 @@ public struct VCamMenu: View { } } +private struct MenuBottomView: View { + @State private var isScenePopover = false + + @ObservedObject private var recorder = VideoRecorder.shared + + @Environment(\.locale) private var locale + + var body: some View { + VStack(spacing: 2) { + HStack { + Spacer() + Button { + MacWindowManager.shared.openSettings() + } label: { + Image(systemName: "gearshape.fill") + .macHoverEffect() + } + .buttonStyle(.plain) + .disabled(recorder.isRecording) + } + .controlSize(.small) + + Divider() + .padding(.bottom, 8) + + VCamMainObjectListView() + .overlay(alignment: .topTrailing) { + Button { + isScenePopover.toggle() + } label: { + Image(systemName: "square.3.stack.3d.top.fill") + .macHoverEffect() + } + .buttonStyle(.plain) + .popover(isPresented: $isScenePopover) { + VCamPopoverContainerWithWindow(L10n.scene.key) { + VCamSceneListView() + } + .frame(width: 200, height: 240) + .environment(\.locale, locale) + } + .offset(y: -8) + } + } + } +} + private struct VCamMenuButtonStyle: ButtonStyle { let isSelected: Bool @@ -87,8 +129,6 @@ private struct VCamMenuButtonStyle: ButtonStyle { } } -struct VCamMenu_Previews: PreviewProvider { - static var previews: some View { - VCamMenu(bottomView: Color.red.frame(height: 200)) - } +#Preview { + VCamMenu() } diff --git a/app/xcode/Sources/VCamUI/VCamSceneListView.swift b/app/xcode/Sources/VCamUI/VCamSceneListView.swift new file mode 100644 index 0000000..a0c72f8 --- /dev/null +++ b/app/xcode/Sources/VCamUI/VCamSceneListView.swift @@ -0,0 +1,168 @@ +// +// VCamSceneListView.swift +// +// +// Created by Tatsuya Tanaka on 2022/05/06. +// + +import SwiftUI +import VCamEntity +import VCamData +import VCamLogger + +public struct VCamSceneListView: View { + public init() {} + + @ObservedObject private var sceneManager = SceneManager.shared + + @State private var editingId: Int32? + @State private var selectedId: Int32? + + public var body: some View { + GroupBox { + List(selection: $selectedId) { + ForEach($sceneManager.scenes) { $scene in + TextFieldListRow( + id: scene.id, + text: $scene.name, + editingId: $editingId, + selectedId: selectedId + ) { + uniUpdateScene() + } + .modifier(EditSceneViewModifier(scene: scene)) + .tag(scene.id) + } + .onMove { source, destination in + sceneManager.move(fromOffsets: source, toOffset: destination) + } + .onChange(of: selectedId) { + guard let newId = $0 else { + selectedId = sceneManager.currentSceneId + return + } + if sceneManager.currentSceneId != newId { + try? sceneManager.loadScene(id: newId) + } + selectedId = newId + } + .onChange(of: sceneManager.currentSceneId) { + selectedId = $0 + } + .onAppear { + selectedId = sceneManager.currentSceneId + } + } + + HStack { + Button { + try? sceneManager.addNewScene() + } label: { + Image(systemName: "plus").background(Color.clear) + } + .buttonStyle(.borderless) + .contentShape(Rectangle()) + + Group { + Button { + if let selectedId = selectedId { + sceneManager.remove(byId: selectedId) + } + } label: { + Image(systemName: "minus").background(Color.clear).frame(height: 14) + } + .contentShape(Rectangle()) + + Button { + if let selectedId = selectedId { + sceneManager.move(byId: selectedId, up: false) + } + } label: { + Image(systemName: "chevron.up") + } + Button { + if let selectedId = selectedId { + sceneManager.move(byId: selectedId, up: true) + } + } label: { + Image(systemName: "chevron.down") + } + } + .disabled(sceneManager.scenes.count == 1) + .buttonStyle(.borderless) + + Spacer() + } + } + .modifierOnMacWindow { content, _ in + content + .padding([.leading, .trailing, .bottom], 8) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 80, maxHeight: .infinity) + .background(.regularMaterial) + } + } +} + +extension VCamSceneListView: MacWindow { + public var windowTitle: String { + L10n.scene.text + } + + public func configureWindow(_ window: NSWindow) -> NSWindow { + window.level = .floating + window.styleMask = [.titled, .closable, .resizable, .fullSizeContentView] + window.setContentSize(.init(width: 200, height: 240)) + window.isOpaque = false + window.backgroundColor = .clear + window.titlebarAppearsTransparent = true + return window + } +} + +private struct DeleteSceneButton: View { + let scene: VCamScene + + var body: some View { + Button(role: .destructive) { + SceneManager.shared.remove(byId: scene.id) + } label: { + Image(systemName: "trash") + Text(L10n.remove.key, bundle: .localize) + } + } +} + +private struct EditSceneViewModifier: ViewModifier { + let scene: VCamScene + + func body(content: Content) -> some View { + content + .contextMenu { + Button { + do { + var duplicatedScene = scene + duplicatedScene.id = Int32.random(in: 0..