diff --git a/PlayerUI/Controllers/PUIDetachedPlaybackStatusViewController.swift b/PlayerUI/Controllers/PUIDetachedPlaybackStatusViewController.swift index e9a1dfce..8418bdf7 100644 --- a/PlayerUI/Controllers/PUIDetachedPlaybackStatusViewController.swift +++ b/PlayerUI/Controllers/PUIDetachedPlaybackStatusViewController.swift @@ -16,6 +16,10 @@ public struct DetachedPlaybackStatus: Identifiable { var title: String var subtitle: String var snapshot: PUISnapshotClosure? + + public var isFullScreen: Bool { + id == "fullScreen" + } } public extension DetachedPlaybackStatus { @@ -65,6 +69,7 @@ public final class PUIDetachedPlaybackStatusViewController: NSViewController { v.imageScaling = .scaleProportionallyUpOrDown v.widthAnchor.constraint(equalToConstant: 74).isActive = true v.heightAnchor.constraint(equalToConstant: 74).isActive = true + v.contentTintColor = .labelColor.preferred(in: .darkAqua) return v }() @@ -73,7 +78,7 @@ public final class PUIDetachedPlaybackStatusViewController: NSViewController { let f = NSTextField(labelWithString: "") f.font = .systemFont(ofSize: 20, weight: .medium) - f.textColor = .labelColor + f.textColor = .labelColor.preferred(in: .darkAqua) f.alignment = .center return f @@ -83,7 +88,7 @@ public final class PUIDetachedPlaybackStatusViewController: NSViewController { let f = NSTextField(labelWithString: "") f.font = .systemFont(ofSize: 16) - f.textColor = .secondaryLabelColor + f.textColor = .secondaryLabelColor.preferred(in: .darkAqua) f.alignment = .center return f diff --git a/PlayerUI/Definitions/Images.swift b/PlayerUI/Definitions/Images.swift index c13c2a4a..d664279a 100644 --- a/PlayerUI/Definitions/Images.swift +++ b/PlayerUI/Definitions/Images.swift @@ -103,6 +103,11 @@ extension NSImage { struct PUIControlMetrics: Hashable { var symbolSize: CGFloat var controlSize: CGFloat + var padding: CGFloat? + var glass: GlassStyle? + enum GlassStyle: Hashable { + case regular, clear + } static let medium = PUIControlMetrics(symbolSize: 16, controlSize: 26) static let large = PUIControlMetrics(symbolSize: 28, controlSize: 38) diff --git a/PlayerUI/Protocols/PUIPlayerViewDelegates.swift b/PlayerUI/Protocols/PUIPlayerViewDelegates.swift index f5cebe29..0221abd1 100644 --- a/PlayerUI/Protocols/PUIPlayerViewDelegates.swift +++ b/PlayerUI/Protocols/PUIPlayerViewDelegates.swift @@ -37,5 +37,11 @@ public protocol PUIPlayerViewAppearanceDelegate: AnyObject, PUIPlayerViewDetache func playerViewShouldShowTimestampLabels(_ playerView: PUIPlayerView) -> Bool func playerViewShouldShowFullScreenButton(_ playerView: PUIPlayerView) -> Bool func playerViewBackAndForwardDuration(_ playerView: PUIPlayerView) -> BackForwardSkipDuration + func playerViewWillHidePlayControls(_ playerView: PUIPlayerView) + func playerViewWillShowPlayControls(_ playerView: PUIPlayerView) +} +public extension PUIPlayerViewAppearanceDelegate { + func playerViewWillHidePlayControls(_ playerView: PUIPlayerView) {} + func playerViewWillShowPlayControls(_ playerView: PUIPlayerView) {} } diff --git a/PlayerUI/Protocols/PUITimelineDelegate.swift b/PlayerUI/Protocols/PUITimelineDelegate.swift index 7cfe9031..3c77cdd4 100644 --- a/PlayerUI/Protocols/PUITimelineDelegate.swift +++ b/PlayerUI/Protocols/PUITimelineDelegate.swift @@ -8,6 +8,7 @@ import Cocoa +@MainActor public protocol PUITimelineDelegate: AnyObject { func viewControllerForTimelineAnnotation(_ annotation: PUITimelineAnnotation) -> NSViewController? diff --git a/PlayerUI/Util/AVPlayer+Layout.swift b/PlayerUI/Util/AVPlayer+Layout.swift index b50dff86..1f71254a 100644 --- a/PlayerUI/Util/AVPlayer+Layout.swift +++ b/PlayerUI/Util/AVPlayer+Layout.swift @@ -28,8 +28,8 @@ public extension AVPlayer { constraints = [ guide.widthAnchor.constraint(equalToConstant: videoRect.width), guide.heightAnchor.constraint(equalToConstant: videoRect.height), - guide.centerYAnchor.constraint(equalTo: container.centerYAnchor), - guide.centerXAnchor.constraint(equalTo: container.centerXAnchor) + guide.centerYAnchor.constraint(equalTo: container.safeAreaLayoutGuide.centerYAnchor), + guide.centerXAnchor.constraint(equalTo: container.safeAreaLayoutGuide.centerXAnchor) ] NSLayoutConstraint.activate(constraints) diff --git a/PlayerUI/Util/NSColor+AppearanceCustomization.swift b/PlayerUI/Util/NSColor+AppearanceCustomization.swift new file mode 100644 index 00000000..0bc736f8 --- /dev/null +++ b/PlayerUI/Util/NSColor+AppearanceCustomization.swift @@ -0,0 +1,26 @@ +// +// NSColor+AppearanceCustomization.swift +// WWDC +// +// Created by luca on 12.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit + +public extension NSColor { + func preferred(in appearanceName: NSAppearance.Name) -> NSColor { + return NSColor(cgColor: preferredCGColor(in: appearanceName)) ?? self + } + + func preferredCGColor(in appearanceName: NSAppearance.Name) -> CGColor { + guard let appearance = NSAppearance(named: appearanceName) else { + return cgColor + } + var result = cgColor + appearance.performAsCurrentDrawingAppearance { // accessing cgcolor will get the correct color under specific appearance + result = cgColor + } + return result + } +} diff --git a/PlayerUI/Util/NSView+BackgroundExtension.swift b/PlayerUI/Util/NSView+BackgroundExtension.swift new file mode 100644 index 00000000..057a60c8 --- /dev/null +++ b/PlayerUI/Util/NSView+BackgroundExtension.swift @@ -0,0 +1,35 @@ +// +// NSView+BackgroundExtension.swift +// WWDC +// +// Created by luca on 11.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import SwiftUI + +public extension NSView { + func backgroundExtensionEffect(reflect edges: Edge.Set = .all, isEnabled: Bool = true) -> NSView { + if isEnabled, #available(macOS 26.0, *) { + let extensionView = NSBackgroundExtensionView() + extensionView.contentView = self + extensionView.automaticallyPlacesContentView = false + extensionView.translatesAutoresizingMaskIntoConstraints = false + // only enable reflection effect on leading edge + let targetLeadingAnchor = edges.contains(.leading) ? extensionView.safeAreaLayoutGuide.leadingAnchor : extensionView.leadingAnchor + let targetTopAnchor = edges.contains(.top) ? extensionView.safeAreaLayoutGuide.topAnchor : extensionView.topAnchor + let targetTrailingAnchor = edges.contains(.trailing) ? extensionView.safeAreaLayoutGuide.trailingAnchor : extensionView.trailingAnchor + let targetBottomAnchor = edges.contains(.bottom) ? extensionView.safeAreaLayoutGuide.bottomAnchor : extensionView.bottomAnchor + NSLayoutConstraint.activate([ + topAnchor.constraint(equalTo: targetTopAnchor), + leadingAnchor.constraint(equalTo: targetLeadingAnchor), + bottomAnchor.constraint(equalTo: targetBottomAnchor), + trailingAnchor.constraint(equalTo: targetTrailingAnchor) + ]) + return extensionView + } else { + return self + } + } +} diff --git a/PlayerUI/Util/NSView+Glass.swift b/PlayerUI/Util/NSView+Glass.swift new file mode 100644 index 00000000..680ad640 --- /dev/null +++ b/PlayerUI/Util/NSView+Glass.swift @@ -0,0 +1,195 @@ +// +// NSView+Glass.swift +// WWDC +// +// Created by luca on 12.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import SwiftUI + +public extension NSView { + @available(macOS 26.0, *) + func glassEffect(style: NSGlassEffectView.Style? = nil, cornerRadius: CGFloat? = nil, tintColor: NSColor? = nil) -> NSView { + let effectView = NSGlassEffectView() + if let cornerRadius { + effectView.cornerRadius = cornerRadius + } + if let style { + effectView.style = style + } + effectView.contentView = self + effectView.tintColor = tintColor + return effectView + } + + // will be bridged to swiftui and back + @available(macOS 26.0, *) + func glassCapsuleEffect(_ glass: Glass = .regular, background: Color? = nil) -> NSView { + NSHostingView(rootView: ConditionalGlassViewWrapper(subview: self, glass: glass, background: background, padding: nil, shape: .capsule)) + } + + // will be bridged to swiftui and back + @available(macOS 26.0, *) + func glassCircleEffect(_ glass: Glass = .regular, background: Color? = nil, padding: CGFloat? = nil) -> NSView { + NSHostingView(rootView: ConditionalGlassViewWrapper(subview: self, glass: glass, background: background, padding: padding, shape: .circle)) + } + + @available(macOS 26.0, *) + static func horizontalGlassContainer(_ glass: Glass = .regular, background: Color? = nil, paddingEdge: Edge.Set = .all, padding: CGFloat? = nil, containerSpacing: CGFloat? = nil, spacing: CGFloat? = nil, groups: [[NSView]]) -> NSView { + NSHostingView(rootView: GroupedHorizontalGlassContainer(axis: .horizontal, subviewGroups: groups, containerSpacing: containerSpacing, spacing: spacing, glass: glass, background: background, paddingEdge: paddingEdge, padding: padding, shape: .capsule)) + } + + @available(macOS 26.0, *) + static func verticalGlassContainer(_ glass: Glass = .regular, background: Color? = nil, paddingEdge: Edge.Set = .all, padding: CGFloat? = nil, containerSpacing: CGFloat? = nil, spacing: CGFloat? = nil, groups: [[NSView]]) -> NSView { + NSHostingView(rootView: GroupedHorizontalGlassContainer(axis: .vertical, subviewGroups: groups, containerSpacing: containerSpacing, spacing: spacing, glass: glass, background: background, paddingEdge: paddingEdge, padding: padding, shape: .capsule)) + } +} + +@available(macOS 26.0, *) +private struct GroupedHorizontalGlassContainer: View { + let axis: Axis.Set + let subviewGroups: [[NSView]] + let containerSpacing: CGFloat? + let spacing: CGFloat? + let glass: Glass + let background: Color? + let paddingEdge: Edge.Set + let padding: CGFloat? + let shape: S + @Namespace private var namespace + var body: some View { + GlassEffectContainer(spacing: containerSpacing ?? spacing) { + Stack(axis: axis, spacing: spacing) { + ForEach(subviewGroups.indices, id: \.self) { groupIdx in + ConditionalHorizontalGlassViewWrapper( + axis: axis, + subviews: subviewGroups[groupIdx], + spacing: spacing, + glass: glass, + tint: background, + paddingEdge: paddingEdge, + padding: padding, + id: "\(groupIdx)", + namespace: namespace, + shape: shape + ) + } + } + } + } + + @available(macOS 26.0, *) + private struct ConditionalHorizontalGlassViewWrapper: View { + let axis: Axis.Set + let subviews: [NSView] + let spacing: CGFloat? + let glass: Glass + let background: Color? + let paddingEdge: Edge.Set + let padding: CGFloat? + let id: String + let namespace: Namespace.ID + let shape: S + @State private var isSubviewsHidden: [Bool] + init(axis: Axis.Set, subviews: [NSView], spacing: CGFloat?, glass: Glass, tint: Color?, paddingEdge: Edge.Set, padding: CGFloat?, id: String, namespace: Namespace.ID, shape: S) { + self.axis = axis + self.subviews = subviews + self.spacing = spacing + self.glass = glass + self.background = tint + self.paddingEdge = paddingEdge + self.padding = padding + self.isSubviewsHidden = subviews.map(\.isHidden) + self.id = id + self.namespace = namespace + self.shape = shape + } + + var body: some View { + Stack(axis: axis, spacing: spacing) { + ForEach(subviews.indices, id: \.self) { idx in + ConditionalViewWrapper(subview: subviews[idx], isHidden: $isSubviewsHidden[idx]) + } + } + .padding(paddingEdge, padding) + .background(isSubviewsHidden.allSatisfy({ $0 }) ? .clear : background) + .clipShape(shape) + .glassEffect(isSubviewsHidden.allSatisfy({ $0 }) ? .identity : glass, in: .capsule) + .glassEffectID(id, in: namespace) + .opacity(isSubviewsHidden.allSatisfy({ $0 }) ? 0 : 1) + } + } +} + +extension View { + @ViewBuilder + func Stack(axis: Axis.Set, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) -> some View { + if axis.contains(.vertical) { + VStack(spacing: spacing, content: content) + } else { + HStack(spacing: spacing, content: content) + } + } +} + +@available(macOS 26.0, *) +private struct ConditionalViewWrapper: View { + let subview: NSView + @State private var isSubviewHidden: Bool = false + var isHidden: Binding? + var body: some View { + Group { + if !isSubviewHidden { + ViewWrapper(view: subview) + .help(subview.toolTip ?? "") + } + } + .onReceive(subview.publisher(for: \.isHidden).removeDuplicates()) { newValue in + withAnimation { + isSubviewHidden = newValue + isHidden?.wrappedValue = newValue + } + } + } +} + +@available(macOS 26.0, *) +private struct ConditionalGlassViewWrapper: View { + let subview: NSView + let glass: Glass + let background: Color? + let padding: CGFloat? + let shape: S + @State private var isSubviewHidden: Bool = false + var isHidden: Binding? + var body: some View { + Group { + if !isSubviewHidden { + ViewWrapper(view: subview) + .padding(.all, padding) + .background(background) + .clipShape(shape) + .glassEffect(glass, in: shape) + .help(subview.toolTip ?? "") + .transition(.blurReplace) + } + } + .onReceive(subview.publisher(for: \.isHidden).removeDuplicates()) { newValue in + withAnimation { + isSubviewHidden = newValue + isHidden?.wrappedValue = newValue + } + } + } +} + +private struct ViewWrapper: NSViewRepresentable { + let view: NSView + func makeNSView(context: Context) -> NSView { + view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/PlayerUI/Views/PUIButton.swift b/PlayerUI/Views/PUIButton.swift index de6153e0..3d4027f2 100755 --- a/PlayerUI/Views/PUIButton.swift +++ b/PlayerUI/Views/PUIButton.swift @@ -10,7 +10,14 @@ import Cocoa import SwiftUI import AVKit -public final class PUIButton: NSControl, ObservableObject { +public protocol StatefulControl: NSControl { + var state: NSControl.StateValue { get set } +} + +extension NSButton: StatefulControl {} +extension NSSwitch: StatefulControl {} + +public final class PUIButton: NSControl, ObservableObject, StatefulControl { public override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -18,6 +25,12 @@ public final class PUIButton: NSControl, ObservableObject { setup() } + init(metrics: PUIControlMetrics = .medium) { + _metrics = .init(initialValue: metrics) + super.init(frame: .zero) + setup() + } + public required init?(coder: NSCoder) { fatalError() } @@ -58,7 +71,12 @@ public final class PUIButton: NSControl, ObservableObject { } private func setup() { - let host = PUIFirstMouseHostingView(rootView: PUIButtonContent(button: self)) + let host: NSView + if #available(macOS 26.0, *), metrics.glass != nil { + host = PUIFirstMouseHostingView(rootView: PUIGlassyButtonContent(button: self)) + } else { + host = PUIFirstMouseHostingView(rootView: PUIButtonContent(button: self)) + } host.translatesAutoresizingMaskIntoConstraints = false addSubview(host) NSLayoutConstraint.activate([ @@ -210,6 +228,65 @@ private struct PUIButtonContent: View { } } +@available(macOS 26.0, *) +private struct PUIGlassyButtonContent: View { + @ObservedObject var button: PUIButton + + private var currentImage: Image? { + if let alternateImage = button.alternateImage, button.state == .on { + return Image(nsImage: alternateImage.withPlayerMetrics(button.metrics)) + } else if let image = button.image { + return Image(nsImage: image.withPlayerMetrics(button.metrics)) + } else { + return nil + } + } + + private var opacity: CGFloat { + guard button.isEnabled else { return 0.5 } + + guard !button.shouldAlwaysDrawHighlighted else { return 1.0 } + + return button.shouldDrawHighlighted ? 0.8 : 1.0 + } + + private var scale: CGFloat { + guard button.isEnabled, !button.shouldAlwaysDrawHighlighted else { return 1 } + + return button.shouldDrawHighlighted ? 0.9 : 1.0 + } + + var body: some View { + ZStack { + if button.isToggle { + glyph + .id(button.state) + .transition(.scale(scale: 0.2).combined(with: .opacity)) + } else { + glyph + } + } + .padding(.all, button.metrics.padding) + .opacity(opacity) + .background(button.metrics.glass.flatMap { _ in Color.black.opacity(0.2) }) // make the label more readable + .clipShape(.circle) + .glassEffect(button.metrics.glass.flatMap({ $0 == .clear ? Glass.clear : .regular }) ?? .identity, in: .circle) + .animation(.snappy(extraBounce: button.state == .on ? 0.25 : 0), value: button.state) + } + + @ViewBuilder + private var glyph: some View { + if let currentImage { + currentImage + .resizable() + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .frame(width: button.metrics.controlSize, height: button.metrics.controlSize) + } + } +} + final class PUIFirstMouseButton: NSButton { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } @@ -219,3 +296,55 @@ private final class PUIFirstMouseHostingView: NSHostingView Bool { true } } + +final class PUIAVRoutPickerView: NSView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + var player: AVPlayer? { + get { routePicker.player } + set { routePicker.player = newValue } + } + + private lazy var routePicker = AVRoutePickerView() + + private func setup() { + let metrics = PUIControlMetrics.medium + let imageView = NSImageView(image: .PUIAirPlay.withPlayerMetrics(metrics)) + imageView.contentTintColor = .white.withAlphaComponent(0.9) + imageView.imageScaling = .scaleNone + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + [AVRoutePickerView.ButtonState.normal, .normalHighlighted, .active, .activeHighlighted].forEach { + routePicker.setRoutePickerButtonColor(.clear, for: $0) + } + routePicker.removeFromSuperview() + routePicker.translatesAutoresizingMaskIntoConstraints = false + addSubview(routePicker) + NSLayoutConstraint.activate([ + routePicker.leadingAnchor.constraint(equalTo: leadingAnchor), + routePicker.trailingAnchor.constraint(equalTo: trailingAnchor), + routePicker.topAnchor.constraint(equalTo: topAnchor), + routePicker.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + // Hack alert + routePicker.alphaValue = 0.01 + } +} diff --git a/PlayerUI/Views/PUIPlaybackSpeedToggle.swift b/PlayerUI/Views/PUIPlaybackSpeedToggle.swift index 595375a5..7a71cc68 100644 --- a/PlayerUI/Views/PUIPlaybackSpeedToggle.swift +++ b/PlayerUI/Views/PUIPlaybackSpeedToggle.swift @@ -9,6 +9,12 @@ final class PUIPlaybackSpeedToggle: NSView, ObservableObject { @Published var isEditingCustomSpeed = false + var borderWidth: CGFloat = 1 + var labelColor: NSColor = .secondaryLabelColor + var editingBackgroundColor: NSColor = .tertiaryLabelColor + var editingBorderColor: NSColor = .labelColor + var cornerRadius: CGFloat = 6 + override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -73,22 +79,25 @@ private struct PlaybackSpeedToggle: View { var body: some View { ZStack { shape - .fill(.tertiary) + .fill(Color(nsColor: controller.editingBackgroundColor)) .opacity(controller.isEditingCustomSpeed ? 1 : 0) shape - .strokeBorder(controller.isEditingCustomSpeed ? .primary : .secondary, lineWidth: 1) + .strokeBorder(controller.isEditingCustomSpeed ? Color(nsColor: controller.editingBorderColor) : Color(nsColor: controller.labelColor), lineWidth: controller.borderWidth) - if controller.isEditingCustomSpeed { - customSpeedEditor - } else { - toggleButton - } + customSpeedEditor + .opacity(controller.isEditingCustomSpeed ? 1 : 0) + .matchedGeometryEffect(id: "text", in: transition, isSource: false) + toggleButton + .buttonStyle(.playerControlStatic) + .opacity(controller.isEditingCustomSpeed ? 0 : 1) + .matchedGeometryEffect(id: "text", in: transition, isSource: true) + .frame(width: 40, height: 20) + .contentShape(shape) } .font(.system(size: 12, weight: .medium)) .monospacedDigit() .frame(width: 40, height: 20) - .buttonStyle(.playerControlStatic) .contentShape(shape) .overlay { if controller.isEditingCustomSpeed, customSpeedInvalid { @@ -132,10 +141,10 @@ private struct PlaybackSpeedToggle: View { .numericContentTransition(value: Double(controller.speed.rawValue)) Text("×") } - .matchedGeometryEffect(id: "text", in: transition) - .foregroundStyle(.primary) + .foregroundStyle(Color(nsColor: controller.labelColor)) .animation(.smooth, value: controller.speed) } + .frame(maxWidth: .infinity, alignment: .center) // fix clicking spaces inside the border .contentShape(Rectangle()) } } @@ -164,7 +173,7 @@ private struct PlaybackSpeedToggle: View { @ViewBuilder private var customSpeedEditor: some View { TextField("Speed", value: $customSpeedValue, formatter: PUIPlaybackSpeed.buttonTitleFormatter) - .matchedGeometryEffect(id: "text", in: transition) + .foregroundStyle(Color(nsColor: controller.labelColor)) .textFieldStyle(.plain) .onEscapePressed { speedFieldFocused = false } .multilineTextAlignment(.center) @@ -188,7 +197,7 @@ private struct PlaybackSpeedToggle: View { } private var shape: some InsettableShape { - RoundedRectangle(cornerRadius: 6, style: .continuous) + RoundedRectangle(cornerRadius: controller.cornerRadius, style: .continuous) } } diff --git a/PlayerUI/Views/PUIPlayerView.swift b/PlayerUI/Views/PUIPlayerView.swift index 33dce798..5e1a9470 100644 --- a/PlayerUI/Views/PUIPlayerView.swift +++ b/PlayerUI/Views/PUIPlayerView.swift @@ -30,6 +30,7 @@ public final class PUIPlayerView: NSView { public weak var delegate: PUIPlayerViewDelegate? public var isInPictureInPictureMode: Bool { pipController?.isPictureInPictureActive == true } + private var enteringPipAfterExitFullscreen: Bool = false public weak var appearanceDelegate: PUIPlayerViewAppearanceDelegate? { didSet { @@ -37,6 +38,8 @@ public final class PUIPlayerView: NSView { } } + var shouldAdoptLiquidGlass = false + public var timelineDelegate: PUITimelineDelegate? { get { return timelineView.delegate @@ -87,13 +90,14 @@ public final class PUIPlayerView: NSView { set { layer?.backgroundColor = newValue?.cgColor } } - public init(player: AVPlayer) { + public init(player: AVPlayer, shouldAdoptLiquidGlass: Bool = false) { self.player = player if AVPictureInPictureController.isPictureInPictureSupported() { self.pipController = AVPictureInPictureController(contentSource: .init(playerLayer: playerLayer)) } else { self.pipController = nil } + self.shouldAdoptLiquidGlass = shouldAdoptLiquidGlass super.init(frame: .zero) @@ -102,7 +106,11 @@ public final class PUIPlayerView: NSView { backgroundColor = .black setupPlayer(player) - setupControls() + if #available(macOS 26.0, *), shouldAdoptLiquidGlass { + setupTahoeControls() + } else { + setupControls() + } } public required init?(coder: NSCoder) { @@ -160,6 +168,7 @@ public final class PUIPlayerView: NSView { didSet { controlsContainerView.isHidden = hideAllControls topTrailingMenuContainerView.isHidden = hideAllControls + scrimContainerView.isHidden = hideAllControls } } @@ -181,6 +190,7 @@ public final class PUIPlayerView: NSView { } private let playerLayer = PUIBoringPlayerLayer() + private var dimmingView: NSView? private func setupPlayer(_ player: AVPlayer) { /// User settings are applied before setting up player observations, avoiding accidental overrides when initial values come in. @@ -245,7 +255,8 @@ public final class PUIPlayerView: NSView { } player.allowsExternalPlayback = true - routeButton.player = player + (routeButton as? PUIButton)?.player = player + (routeButton as? PUIAVRoutPickerView)?.player = player setupNowPlayingCoordinatorIfSupported() setupRemoteCommandCoordinator() @@ -285,17 +296,25 @@ public final class PUIPlayerView: NSView { settings.playerVolume = Double(player.volume) if player.volume.isZero { - volumeButton.image = .PUIVolumeMuted + if shouldAdoptLiquidGlass { + volumeButton.image = NSImage(systemSymbolName: "speaker.wave.3.fill", variableValue: 0, accessibilityDescription: "Volume") + } else { + volumeButton.image = .PUIVolumeMuted + } volumeButton.toolTip = "Unmute" volumeSlider.doubleValue = 0 } else { - switch player.volume { - case 0..<0.33: - volumeButton.image = .PUIVolume1 - case 0.33..<0.66: - volumeButton.image = .PUIVolume2 - default: - volumeButton.image = .PUIVolume3 + if shouldAdoptLiquidGlass { + volumeButton.image = NSImage(systemSymbolName: "speaker.wave.3.fill", variableValue: Double(player.volume), accessibilityDescription: "Volume") + } else { + switch player.volume { + case 0..<0.33: + volumeButton.image = .PUIVolume1 + case 0.33..<0.66: + volumeButton.image = .PUIVolume2 + default: + volumeButton.image = .PUIVolume3 + } } volumeButton.toolTip = "Mute" @@ -406,10 +425,15 @@ public final class PUIPlayerView: NSView { trailingTimeButton.title = "−" + (String(time: remainingTime) ?? timeRemainingPlaceholder) } } - + lazy var trackingArea = NSTrackingArea(rect: bounds, options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited], owner: self, userInfo: nil) public override func layout() { updateVideoLayoutGuide() + if shouldAdoptLiquidGlass { + if !trackingAreas.contains(trackingArea) { + addTrackingArea(trackingArea) + } + } super.layout() } @@ -418,6 +442,9 @@ public final class PUIPlayerView: NSView { private var currentBounds: CGRect? private func updateVideoLayoutGuide() { + guard !shouldAdoptLiquidGlass else { + return + } guard let player else { return } guard bounds != currentBounds else { return } @@ -486,12 +513,14 @@ public final class PUIPlayerView: NSView { fileprivate var wasPlayingBeforeStartingInteractiveSeek = false - private var topTrailingMenuContainerView: NSStackView! - fileprivate var scrimContainerView: PUIScrimView! - private var controlsContainerView: NSStackView! - private var volumeControlsContainerView: NSStackView! - private var centerButtonsContainerView: NSStackView! - private var timelineContainerView: NSStackView! + private var topTrailingMenuContainerView: NSView! + fileprivate var scrimContainerView: NSView! + private var controlsContainerView: NSView! + private var volumeControlsContainerView: NSView! + private var centerButtonsContainerView: NSView! + private var timelineContainerView: NSView! + private var floatingTimestampView: NSView? + private var floatingTimestampModel: PUITimelineFloatingModel? fileprivate lazy var timelineView: PUITimelineView = { let v = PUITimelineView(frame: .zero) @@ -533,14 +562,12 @@ public final class PUIPlayerView: NSView { return b }() - private lazy var fullScreenButton: PUIVibrantButton = { + private lazy var fullScreenButton: NSView = { let b = PUIVibrantButton(frame: .zero) - b.button.image = .PUIFullScreen b.button.target = self b.button.action = #selector(toggleFullscreen) b.button.toolTip = "Toggle full screen" - return b }() @@ -574,7 +601,7 @@ public final class PUIPlayerView: NSView { return s }() - private lazy var subtitlesButton: PUIButton = { + private lazy var subtitlesButton: NSControl = { let b = PUIButton(frame: .zero) b.image = .PUISubtitles @@ -586,7 +613,14 @@ public final class PUIPlayerView: NSView { }() fileprivate lazy var playButton: PUIButton = { - let b = PUIButton(frame: .zero) + let b: PUIButton + if shouldAdoptLiquidGlass { + var metrics = PUIControlMetrics.large + metrics.glass = .clear + b = PUIButton(metrics: metrics) + } else { + b = PUIButton(metrics: .large) + } b.isToggle = true b.image = .PUIPlay @@ -596,14 +630,20 @@ public final class PUIPlayerView: NSView { b.target = self b.action = #selector(togglePlaying) b.toolTip = "Play/pause" - b.metrics = .large return b }() private lazy var backButton: PUIButton = { - let b = PUIButton(frame: .zero) - + let b: PUIButton + if shouldAdoptLiquidGlass { + var metrics = PUIControlMetrics.medium + metrics.glass = .clear + metrics.padding = 5 + b = PUIButton(metrics: metrics) + } else { + b = PUIButton(frame: .zero) + } b.image = .PUIBack15s b.target = self b.action = #selector(goBackInTime) @@ -613,7 +653,15 @@ public final class PUIPlayerView: NSView { }() private lazy var forwardButton: PUIButton = { - let b = PUIButton(frame: .zero) + let b: PUIButton + if shouldAdoptLiquidGlass { + var metrics = PUIControlMetrics.medium + metrics.glass = .clear + metrics.padding = 5 + b = PUIButton(metrics: metrics) + } else { + b = PUIButton(frame: .zero) + } b.image = .PUIForward15s b.target = self @@ -625,7 +673,7 @@ public final class PUIPlayerView: NSView { fileprivate lazy var speedButton = PUIPlaybackSpeedToggle(frame: .zero) - private lazy var addAnnotationButton: PUIButton = { + private lazy var addAnnotationButton: NSControl = { let b = PUIButton(frame: .zero) b.image = .PUIAnnotation @@ -637,7 +685,7 @@ public final class PUIPlayerView: NSView { return b }() - private lazy var pipButton: PUIButton = { + private lazy var pipButton: StatefulControl = { let b = PUIButton(frame: .zero) b.isToggle = true @@ -652,7 +700,7 @@ public final class PUIPlayerView: NSView { return b }() - private lazy var routeButton: PUIButton = { + private lazy var routeButton: NSView = { let b = PUIButton(frame: .zero) b.isToggle = true @@ -670,7 +718,6 @@ public final class PUIPlayerView: NSView { private func setupControls() { addLayoutGuide(videoLayoutGuide) - let playerView = NSView() playerView.translatesAutoresizingMaskIntoConstraints = false playerView.wantsLayer = true @@ -683,14 +730,16 @@ public final class PUIPlayerView: NSView { playerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true // Volume controls - volumeControlsContainerView = NSStackView(views: [volumeButton, volumeSlider]) + let volumeControlsContainerView = NSStackView(views: [volumeButton, volumeSlider]) + self.volumeControlsContainerView = volumeControlsContainerView volumeControlsContainerView.orientation = .horizontal volumeControlsContainerView.spacing = 6 volumeControlsContainerView.alignment = .centerY // Center Buttons - centerButtonsContainerView = NSStackView(frame: bounds) + let centerButtonsContainerView = NSStackView(frame: bounds) + self.centerButtonsContainerView = centerButtonsContainerView // Leading controls (volume, subtitles) centerButtonsContainerView.addView(volumeButton, in: .leading) @@ -728,20 +777,22 @@ public final class PUIPlayerView: NSView { centerButtonsContainerView.setVisibilityPriority(.detachOnlyIfNecessary, for: pipButton) centerButtonsContainerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - timelineContainerView = NSStackView(views: [ + let timelineContainerView = NSStackView(views: [ leadingTimeButton, timelineView, trailingTimeButton ]) + self.timelineContainerView = timelineContainerView timelineContainerView.distribution = .equalSpacing timelineContainerView.orientation = .horizontal timelineContainerView.alignment = .centerY // Main stack view and background scrim - controlsContainerView = NSStackView(views: [ + let controlsContainerView = NSStackView(views: [ timelineContainerView, centerButtonsContainerView ]) + self.controlsContainerView = controlsContainerView controlsContainerView.orientation = .vertical controlsContainerView.spacing = 12 @@ -783,7 +834,8 @@ public final class PUIPlayerView: NSView { centerButtonsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true centerButtonsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true - topTrailingMenuContainerView = NSStackView(views: [fullScreenButton]) + let topTrailingMenuContainerView = NSStackView(views: [fullScreenButton]) + self.topTrailingMenuContainerView = topTrailingMenuContainerView topTrailingMenuContainerView.orientation = .horizontal topTrailingMenuContainerView.alignment = .centerY topTrailingMenuContainerView.distribution = .equalSpacing @@ -899,6 +951,9 @@ public final class PUIPlayerView: NSView { } private func updateTopTrailingMenuPosition() { + guard !shouldAdoptLiquidGlass else { + return + } let topConstant: CGFloat = isDominantViewInWindow ? 34 : 12 if topTrailingMenuTopConstraint == nil { @@ -1029,6 +1084,7 @@ public final class PUIPlayerView: NSView { pipController?.stopPictureInPicture() } else { pipController?.startPictureInPicture() + enteringPipAfterExitFullscreen = windowIsInFullScreen } } @@ -1292,7 +1348,11 @@ public final class PUIPlayerView: NSView { let viewMouseRect = convert(windowMouseRect, from: nil) // don't hide the controls when the mouse is over them - return !viewMouseRect.intersects(controlsContainerView.frame) + if shouldAdoptLiquidGlass { + return hitTest(CGPoint(x: viewMouseRect.minX, y: viewMouseRect.midY)) == controlsContainerView || !viewMouseRect.intersects(controlsContainerView.frame) + } else { + return !viewMouseRect.intersects(controlsContainerView.frame) + } } fileprivate var mouseIdleTimer: Timer! @@ -1318,9 +1378,12 @@ public final class PUIPlayerView: NSView { hideControls(animated: true) } - private func hideControls(animated: Bool) { + private func hideControls(animated: Bool, isMouseInToolbar: Bool = false) { guard canHideControls else { return } + if !isMouseInToolbar { + appearanceDelegate?.playerViewWillHidePlayControls(self) + } setControls(opacity: 0, animated: animated) } @@ -1329,15 +1392,18 @@ public final class PUIPlayerView: NSView { guard isEnabled else { return } + appearanceDelegate?.playerViewWillShowPlayControls(self) setControls(opacity: 1, animated: animated) } private func setControls(opacity: CGFloat, animated: Bool) { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = animated ? 0.4 : 0.0 + scrimContainerView.animator().alphaValue = opacity controlsContainerView.animator().alphaValue = opacity topTrailingMenuContainerView.animator().alphaValue = opacity + dimmingView?.animator().alphaValue = opacity }, completionHandler: nil) } @@ -1353,6 +1419,11 @@ public final class PUIPlayerView: NSView { NotificationCenter.default.addObserver(self, selector: #selector(windowDidExitFullScreen), name: NSWindow.didExitFullScreenNotification, object: newWindow) NotificationCenter.default.addObserver(self, selector: #selector(windowDidResignMain), name: NSWindow.didResignMainNotification, object: newWindow) NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeMain), name: NSWindow.didBecomeMainNotification, object: newWindow) + if newWindow == nil { + appearanceDelegate?.playerViewWillShowPlayControls(self) + } else { + appearanceDelegate?.playerViewWillHidePlayControls(self) + } } public override func viewDidMoveToWindow() { @@ -1375,11 +1446,21 @@ public final class PUIPlayerView: NSView { } private var isTransitioningFromFullScreenPlayback = false + private var currentDetachedStatus: DetachedPlaybackStatus? + + public func resumeDetachedStatusIfNeeded() { + guard let currentDetachedStatus else { return } + appearanceDelegate?.dismissDetachedStatus(currentDetachedStatus, for: self) // this gives the delegate the chance to reset some state variables + appearanceDelegate?.presentDetachedStatus(currentDetachedStatus, for: self) + + } @objc private func windowWillEnterFullScreen() { guard window is PUIPlayerWindow else { return } - appearanceDelegate?.presentDetachedStatus(.fullScreen.snapshot(using: snapshotClosure), for: self) + let status = DetachedPlaybackStatus.fullScreen.snapshot(using: snapshotClosure) + appearanceDelegate?.presentDetachedStatus(status, for: self) + currentDetachedStatus = status fullScreenButton.isHidden = true updateTopTrailingMenuPosition() @@ -1412,6 +1493,12 @@ public final class PUIPlayerView: NSView { /// The detached status presentation takes care of leaving a black background before we finish the full screen transition. appearanceDelegate?.dismissDetachedStatus(.fullScreen, for: self) + currentDetachedStatus = nil + if enteringPipAfterExitFullscreen { + let status = DetachedPlaybackStatus.pictureInPicture.snapshot(using: snapshotClosure) + appearanceDelegate?.presentDetachedStatus(status, for: self) + currentDetachedStatus = status + } } @objc private func windowDidBecomeMain() { @@ -1458,6 +1545,12 @@ public final class PUIPlayerView: NSView { isPointInsideTimelineArea(convert(event.locationInWindow, from: nil)) } + private func isMouseInToolbarArea(_ mouseLocationInWindow: NSPoint) -> Bool { + let viewMouseLocation = convert(mouseLocationInWindow, from: nil) + let topRect = CGRect(x: controlsContainerView.safeAreaRect.minX, y: controlsContainerView.safeAreaRect.maxY, width: controlsContainerView.safeAreaRect.width, height: controlsContainerView.safeAreaInsets.top) + return topRect.contains(viewMouseLocation) + } + private func isPointInsideTimelineArea(_ pointInViewCoordinates: CGPoint) -> Bool { let point = convert(pointInViewCoordinates, to: timelineView) return timelineView.hoverBounds.contains(point) @@ -1486,6 +1579,9 @@ public final class PUIPlayerView: NSView { timelineView.mouseExited(with: event) } } + if shouldAdoptLiquidGlass, isMouseInToolbarArea(event.locationInWindow) { + hideControls(animated: true, isMouseInToolbar: true) + } } public override func mouseExited(with event: NSEvent) { @@ -1574,6 +1670,16 @@ extension PUIPlayerView: PUITimelineViewDelegate { } } + func timelineViewFloatingTimeIndicatorDidUpdate(at timestamp: Double?, suggestedFrame: CGRect?, isHidden: Bool) { + if let suggestedFrame { + let localeFrame = convert(suggestedFrame, from: timelineView) + floatingTimestampView?.frame = localeFrame + } + if let text = timestamp.flatMap(String.init(timestamp:)) { + floatingTimestampModel?.text = text + } + floatingTimestampModel?.setIsHidden(isHidden) + } } // MARK: - PiP delegate @@ -1595,7 +1701,9 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate { public func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { delegate?.playerViewWillEnterPictureInPictureMode(self) - appearanceDelegate?.presentDetachedStatus(.pictureInPicture.snapshot(using: snapshotClosure), for: self) + let status = DetachedPlaybackStatus.pictureInPicture.snapshot(using: snapshotClosure) + appearanceDelegate?.presentDetachedStatus(status, for: self) + currentDetachedStatus = status } public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { @@ -1647,11 +1755,209 @@ extension PUIPlayerView: AVPictureInPictureControllerDelegate { // Called Last public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { appearanceDelegate?.dismissDetachedStatus(.pictureInPicture, for: self) + currentDetachedStatus = nil pipButton.state = .off invalidateTouchBar() } } +@available(macOS 26.0, *) +private extension PUIPlayerView { + private func setupTahoeControls() { + let playerView = NSView() + playerView.translatesAutoresizingMaskIntoConstraints = false + playerView.wantsLayer = true + playerView.layer = playerLayer + playerLayer.backgroundColor = .clear + + let extensionView = playerView.backgroundExtensionEffect(reflect: .leading) + + addSubview(extensionView) + let dimmingView = NSView() + dimmingView.translatesAutoresizingMaskIntoConstraints = false + dimmingView.wantsLayer = true + dimmingView.layer?.backgroundColor = CGColor(red: 0, green: 0, blue: 0, alpha: 0.3) + dimmingView.alphaValue = 0 + self.dimmingView = dimmingView + addSubview(dimmingView) + NSLayoutConstraint.activate([ + extensionView.topAnchor.constraint(equalTo: topAnchor), + extensionView.leadingAnchor.constraint(equalTo: leadingAnchor), + extensionView.bottomAnchor.constraint(equalTo: bottomAnchor), + extensionView.trailingAnchor.constraint(equalTo: trailingAnchor), + dimmingView.topAnchor.constraint(equalTo: topAnchor), + dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor), + dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor), + dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + + scrimContainerView = NSView() // full content + scrimContainerView.translatesAutoresizingMaskIntoConstraints = false + + controlsContainerView = scrimContainerView // alias + topTrailingMenuContainerView = NSView() // placeholder + + let scrimMarginGuide = NSLayoutGuide() + scrimContainerView.addLayoutGuide(scrimMarginGuide) + addSubview(scrimContainerView) + + NSLayoutConstraint.activate([ + scrimContainerView.topAnchor.constraint(equalTo: topAnchor), + scrimContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrimContainerView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrimContainerView.trailingAnchor.constraint(equalTo: trailingAnchor), + + scrimMarginGuide.topAnchor.constraint(equalTo: scrimContainerView.safeAreaLayoutGuide.topAnchor, constant: 16), + scrimMarginGuide.leadingAnchor.constraint(equalTo: scrimContainerView.safeAreaLayoutGuide.leadingAnchor, constant: 16), + scrimMarginGuide.bottomAnchor.constraint(equalTo: scrimContainerView.safeAreaLayoutGuide.bottomAnchor, constant: -16), + scrimMarginGuide.trailingAnchor.constraint(equalTo: scrimContainerView.safeAreaLayoutGuide.trailingAnchor, constant: -16) + ]) + + // Volume controls + volumeButton = PUIFirstMouseButton(image: NSImage(systemSymbolName: "speaker.wave.3.fill", variableValue: 1, accessibilityDescription: "Volume")!, target: self, action: #selector(toggleMute)) + volumeButton.isBordered = false + volumeButton.contentTintColor = .white.withAlphaComponent(0.9) + volumeButton.imageScaling = .scaleNone + volumeSlider.controlSize = .mini + volumeSlider.trackFillColor = .white.withAlphaComponent(0.9) + + let volumeControlsContainerView = NSView.horizontalGlassContainer(.clear, background: .black.opacity(0.2), paddingEdge: .horizontal, padding: 5, spacing: 2, groups: [[volumeButton, volumeSlider]]) + self.volumeControlsContainerView = volumeControlsContainerView + volumeControlsContainerView.translatesAutoresizingMaskIntoConstraints = false + scrimContainerView.addSubview(volumeControlsContainerView, positioned: .below, relativeTo: timelineContainerView) + NSLayoutConstraint.activate([ + volumeControlsContainerView.leadingAnchor.constraint(equalTo: scrimMarginGuide.leadingAnchor), + volumeControlsContainerView.bottomAnchor.constraint(equalTo: scrimMarginGuide.bottomAnchor), + volumeControlsContainerView.heightAnchor.constraint(equalToConstant: 30) + ]) + + let subtitlesButton = PUIFirstMouseButton(image: .PUISubtitles.withPlayerMetrics(.medium), target: self, action: #selector(showSubtitlesMenu)) + subtitlesButton.toolTip = "Subtitles" + self.subtitlesButton = subtitlesButton + + let addAnnotationButton = PUIFirstMouseButton(image: .PUIAnnotation.withPlayerMetrics(.medium), target: self, action: #selector(addAnnotation)) + addAnnotationButton.toolTip = "Add bookmark" + self.addAnnotationButton = addAnnotationButton + + let fullScreenButton = PUIFirstMouseButton(image: .PUIFullScreen.withPlayerMetrics(.medium), target: self, action: #selector(toggleFullscreen)) + fullScreenButton.toolTip = "Toggle full screen" + self.fullScreenButton = fullScreenButton + + let pipButton = PUIFirstMouseButton(image: AVPictureInPictureController.pictureInPictureButtonStartImage.withPlayerMetrics(.medium), target: self, action: #selector(togglePip)) + pipButton.setButtonType(.toggle) + pipButton.state = .on + pipButton.alternateImage = AVPictureInPictureController.pictureInPictureButtonStopImage.withPlayerMetrics(.medium) + pipButton.toolTip = "Toggle picture in picture" + self.pipButton = pipButton + + _ = routeButton + let picker = PUIAVRoutPickerView() + picker.toolTip = "AirPlay" + self.routeButton = picker + + [subtitlesButton, addAnnotationButton, fullScreenButton, pipButton].forEach { + $0.isBordered = false + $0.imageScaling = .scaleNone + $0.contentTintColor = .white.withAlphaComponent(0.9) + } + + [subtitlesButton, addAnnotationButton, fullScreenButton, pipButton, picker].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.widthAnchor.constraint(equalToConstant: PUIControlMetrics.medium.controlSize).isActive = true + $0.heightAnchor.constraint(equalToConstant: PUIControlMetrics.medium.controlSize).isActive = true + } + + speedButton.labelColor = .white.withAlphaComponent(0.9) + speedButton.borderWidth = 2 + speedButton.editingBorderColor = .white.withAlphaComponent(0.9) + speedButton.editingBackgroundColor = .clear + speedButton.cornerRadius = 10 + let bottomTrailingGroup = NSView.horizontalGlassContainer(.clear, background: .black.opacity(0.2), padding: 5, spacing: 2, groups: [ + [subtitlesButton, addAnnotationButton, speedButton] + ]) + + bottomTrailingGroup.translatesAutoresizingMaskIntoConstraints = false + scrimContainerView.addSubview(bottomTrailingGroup) + NSLayoutConstraint.activate([ + bottomTrailingGroup.centerYAnchor.constraint(equalTo: volumeControlsContainerView.centerYAnchor), + bottomTrailingGroup.trailingAnchor.constraint(equalTo: scrimMarginGuide.trailingAnchor) + ]) + + let model = PUITimelineFloatingModel() + let glassView = NSHostingView(rootView: PUITimelineGlassFloatingView().environment(model)) + floatingTimestampView = glassView + floatingTimestampModel = model + glassView.frame = .zero + addSubview(glassView) + + // Timeline + timelineView = PUITimelineView(adoptLiquidGlass: true) + timelineView.viewDelegate = self + let timelineContainerView = NSStackView(views: [ + leadingTimeButton, + timelineView, + trailingTimeButton + ]) + [leadingTimeButton, trailingTimeButton].forEach { + $0.contentTintColor = .white + } + timelineContainerView.distribution = .equalSpacing + timelineContainerView.orientation = .horizontal + timelineContainerView.alignment = .centerY + self.timelineContainerView = timelineContainerView + + scrimContainerView.addSubview(timelineContainerView) + timelineContainerView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + timelineContainerView.leadingAnchor.constraint(equalTo: scrimMarginGuide.leadingAnchor), + timelineContainerView.bottomAnchor.constraint(equalTo: bottomTrailingGroup.topAnchor, constant: -12), + timelineContainerView.trailingAnchor.constraint(equalTo: scrimMarginGuide.trailingAnchor) + ]) + + // Center controls (play, forward, backward) + [backButton, playButton, forwardButton].forEach { + scrimContainerView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: scrimContainerView.centerYAnchor).isActive = true + } + + let spacing = CGFloat(30) + NSLayoutConstraint.activate([ + playButton.centerXAnchor.constraint(equalTo: scrimMarginGuide.centerXAnchor), + backButton.trailingAnchor.constraint(equalTo: playButton.leadingAnchor, constant: -spacing), + forwardButton.leadingAnchor.constraint(equalTo: playButton.trailingAnchor, constant: spacing) + ]) + + // Center Buttons + centerButtonsContainerView = NSView() // placeholder + + let topLeadingGroups = NSView.horizontalGlassContainer(.clear, background: .black.opacity(0.2), padding: 5, spacing: 2, groups: [ + [fullScreenButton, pipButton, routeButton] + ]) + topLeadingGroups.translatesAutoresizingMaskIntoConstraints = false + scrimContainerView.addSubview(topLeadingGroups) + NSLayoutConstraint.activate([ + topLeadingGroups.leadingAnchor.constraint(equalTo: scrimMarginGuide.leadingAnchor), + topLeadingGroups.topAnchor.constraint(equalTo: scrimMarginGuide.topAnchor) + ]) + + speedButton.$speed.removeDuplicates().sink { [weak self] speed in + guard let self else { return } + self.playbackSpeed = speed + } + .store(in: &uiBindings) + + speedButton.$isEditingCustomSpeed.sink { [weak self] isEditing in + guard let self else { return } + + showControls(animated: false) + resetMouseIdleTimer() + } + .store(in: &uiBindings) + } +} + #if DEBUG struct PUIPlayerView_Previews: PreviewProvider { static var previews: some View { @@ -1665,7 +1971,7 @@ private struct PUIPlayerViewPreviewWrapper: NSViewRepresentable { func makeNSView(context: Context) -> PUIPlayerView { let player = AVPlayer(url: .previewVideoURL) - let view = PUIPlayerView(player: player) + let view = PUIPlayerView(player: player, shouldAdoptLiquidGlass: true) player.seek(to: CMTimeMakeWithSeconds(30, preferredTimescale: 9000)) return view } @@ -1696,3 +2002,4 @@ private extension URL { }() } #endif + diff --git a/PlayerUI/Views/PUITimelineFloatingLayer.swift b/PlayerUI/Views/PUITimelineFloatingLayer.swift index 3bb58aa9..cdb0b865 100644 --- a/PlayerUI/Views/PUITimelineFloatingLayer.swift +++ b/PlayerUI/Views/PUITimelineFloatingLayer.swift @@ -202,8 +202,59 @@ extension CASpringAnimation { } } +@Observable +class PUITimelineFloatingModel { + var text: String? + fileprivate var isHidden = true + + func setIsHidden(_ isHidden: Bool, animated: Bool = true) { + guard animated else { + self.isHidden = isHidden + return + } + withAnimation(.bouncy) { + self.isHidden = isHidden + } + } + + func show(animated: Bool = true) { + setIsHidden(false, animated: animated) + } + + func hide(animated: Bool = true) { + setIsHidden(true, animated: animated) + } +} + +@available(macOS 26.0, *) +struct PUITimelineGlassFloatingView: View { + @Environment(PUITimelineFloatingModel.self) var model + var body: some View { + Group { + if !model.isHidden, let label = model.text { + Text(label) + .font(.system(size: PUITimelineView.Metrics.floatingLayerTextSize)) + .fontDesign(.monospaced) + .fontWeight(.medium) + .foregroundStyle(.white) + .fixedSize() + .padding(.vertical, PUITimelineView.Metrics.floatingLayerMargin) + .padding(.horizontal, PUITimelineView.Metrics.floatingLayerMargin) + .background(Color.black.opacity(0.3)) + .clipShape(.capsule) + .glassEffect(.clear, in: .capsule) + .transition(.scale.combined(with: .opacity)) + } + } + } +} + #if DEBUG struct PUITimelineFloatingLayer_Previews: PreviewProvider { - static var previews: some View { PUIPlayerView_Previews.previews } + static var previews: some View { + if #available(macOS 26.0, *) { + PUITimelineGlassFloatingView() + } + } } #endif diff --git a/PlayerUI/Views/PUITimelineView.swift b/PlayerUI/Views/PUITimelineView.swift index cd4feb8a..b386d043 100644 --- a/PlayerUI/Views/PUITimelineView.swift +++ b/PlayerUI/Views/PUITimelineView.swift @@ -17,7 +17,7 @@ protocol PUITimelineViewDelegate: AnyObject { func timelineViewDidSeek(to progress: Double) func timelineViewDidFinishInteractiveSeek() func timelineDidReceiveForceTouch(at timestamp: Double) - + func timelineViewFloatingTimeIndicatorDidUpdate(at timestamp: Double?, suggestedFrame: CGRect?, isHidden: Bool) } public final class PUITimelineView: NSView { @@ -30,13 +30,16 @@ public final class PUITimelineView: NSView { public override init(frame frameRect: NSRect) { super.init(frame: frameRect) - buildUI() } + init(adoptLiquidGlass: Bool) { + super.init(frame: .zero) + buildUI(adoptLiquidGlass: adoptLiquidGlass) + } + public required init?(coder: NSCoder) { super.init(coder: coder) - buildUI() } @@ -103,7 +106,7 @@ public final class PUITimelineView: NSView { private lazy var floatingTimeLayer = PUITimelineFloatingLayer() - private func buildUI() { + private func buildUI(adoptLiquidGlass: Bool = false) { wantsLayer = true layer = PUIBoringLayer() layer?.masksToBounds = false @@ -111,7 +114,7 @@ public final class PUITimelineView: NSView { // Main border borderLayer = PUIBoringLayer() - borderLayer.borderColor = NSColor.playerBorder.cgColor + borderLayer.borderColor = NSColor.playerBorder.preferredCGColor(in: .darkAqua) borderLayer.borderWidth = 1.0 borderLayer.frame = bounds @@ -128,7 +131,7 @@ public final class PUITimelineView: NSView { // Playback bar playbackProgressLayer = PUIBoringLayer() - playbackProgressLayer.backgroundColor = NSColor.playerProgress.cgColor + playbackProgressLayer.backgroundColor = NSColor.playerProgress.preferredCGColor(in: .darkAqua) playbackProgressLayer.frame = bounds playbackProgressLayer.masksToBounds = true @@ -137,20 +140,23 @@ public final class PUITimelineView: NSView { // Ghost bar seekProgressLayer = PUIBoringLayer() - seekProgressLayer.backgroundColor = NSColor.seekProgress.cgColor + seekProgressLayer.backgroundColor = NSColor.seekProgress.preferredCGColor(in: .darkAqua) seekProgressLayer.frame = bounds layer?.addSublayer(seekProgressLayer) // Floating time - layer?.addSublayer(floatingTimeLayer) + if !adoptLiquidGlass { + layer?.addSublayer(floatingTimeLayer) + } // Annotations container annotationsContainerLayer.frame = bounds annotationsContainerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] annotationsContainerLayer.masksToBounds = false + annotationsContainerLayer.zPosition = 30 layer?.addSublayer(annotationsContainerLayer) #if DEBUG @@ -278,6 +284,7 @@ public final class PUITimelineView: NSView { ) floatingTimeLayer.frame = floatingTimeRect + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: timestamp, suggestedFrame: floatingTimeRect, isHidden: false) } public override var mouseDownCanMoveWindow: Bool { @@ -338,6 +345,7 @@ public final class PUITimelineView: NSView { case .leftMouseDragged?: if !startedInteractiveSeek { floatingTimeLayer.hide() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: true) startedInteractiveSeek = true self.viewDelegate?.timelineViewWillBeginInteractiveSeek() } @@ -354,13 +362,17 @@ public final class PUITimelineView: NSView { private func reactToMouse() { if hasMouseInside { - borderLayer.animate { borderLayer.borderColor = NSColor.highlightedPlayerBorder.cgColor } + borderLayer.animate { borderLayer.borderColor = NSColor.highlightedPlayerBorder.preferredCGColor(in: .darkAqua) } seekProgressLayer.animate { seekProgressLayer.opacity = 1 } floatingTimeLayer.show() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: false) } else { - borderLayer.animate { borderLayer.borderColor = NSColor.playerBorder.cgColor } + borderLayer.animate { borderLayer.borderColor = NSColor.playerBorder.preferredCGColor(in: .darkAqua) } seekProgressLayer.animate { seekProgressLayer.opacity = 0 } - if selectedAnnotation == nil { floatingTimeLayer.hide() } + if selectedAnnotation == nil { + floatingTimeLayer.hide() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: true) + } } } @@ -519,6 +531,7 @@ public final class PUITimelineView: NSView { layer.isHighlighted = true floatingTimeLayer.show() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: false) } private func mouseOut(_ annotation: PUITimelineAnnotation, layer: PUIAnnotationLayer) { @@ -643,6 +656,7 @@ public final class PUITimelineView: NSView { if mode == .delete { floatingTimeLayer.hide() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: true) } case .keyUp?: // cancel with ESC @@ -670,6 +684,7 @@ public final class PUITimelineView: NSView { guard let controller = delegate?.viewControllerForTimelineAnnotation(annotation) else { return } floatingTimeLayer.show() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: false) updateFloatingTime(with: annotationLayer.position) currentAnnotationEditor = controller @@ -744,6 +759,7 @@ public final class PUITimelineView: NSView { UILog(#function) floatingTimeLayer.hide() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: true) if let monitor = annotationCommandsMonitor { NSEvent.removeMonitor(monitor) @@ -786,6 +802,7 @@ private extension PUITimelineView { if forceTimePreviewVisible { self.updateFloatingTime(with: CGPoint(x: 100, y: 0)) self.floatingTimeLayer.show() + viewDelegate?.timelineViewFloatingTimeIndicatorDidUpdate(at: nil, suggestedFrame: nil, isHidden: false) } if addAnnotations { diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 41f91909..c4941377 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -25,6 +25,44 @@ 4DDF6A782177A00C008E5539 /* DownloadsManagementTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDF6A772177A00C008E5539 /* DownloadsManagementTableCellView.swift */; }; 4DF6641620C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6641520C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift */; }; 557221042C190BC0002B42C9 /* OptionalToggleFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557221032C190BC0002B42C9 /* OptionalToggleFilter.swift */; }; + 81057FFA2E489E8F006ACCE7 /* InteractionEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81057FF92E489E8C006ACCE7 /* InteractionEffects.swift */; }; + 81057FFC2E489F74006ACCE7 /* SessionPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81057FFB2E489F70006ACCE7 /* SessionPlayerView.swift */; }; + 810580012E495B09006ACCE7 /* NSView+BackgroundExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 810580002E495B00006ACCE7 /* NSView+BackgroundExtension.swift */; }; + 8115CEA72E4B2C4F001C0FE4 /* NSColor+AppearanceCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8115CEA62E4B2C41001C0FE4 /* NSColor+AppearanceCustomization.swift */; }; + 812EB5812E3E973F00FE869F /* NewTopicHeaderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812EB5802E3E973F00FE869F /* NewTopicHeaderRow.swift */; }; + 812EB5832E3F59FC00FE869F /* NewTranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812EB5822E3F59F300FE869F /* NewTranscriptView.swift */; }; + 8181E2662E39F0770033126E /* ReplaceableSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8181E2652E39F0710033126E /* ReplaceableSplitViewController.swift */; }; + 8181E27A2E3A5E6E0033126E /* NewMainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8181E2792E3A5E620033126E /* NewMainWindowController.swift */; }; + 8181E27C2E3A63550033126E /* WWDCCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8181E27B2E3A634D0033126E /* WWDCCoordinator.swift */; }; + 8181E27E2E3A6CC70033126E /* NewAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8181E27D2E3A6CC20033126E /* NewAppCoordinator.swift */; }; + 8196CAF32E3D6353008482E3 /* GlobalSearchTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CAF22E3D634A008482E3 /* GlobalSearchTabState.swift */; }; + 8196CAF62E3DE0A2008482E3 /* CapsuleToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CAF52E3DE09C008482E3 /* CapsuleToggleStyle.swift */; }; + 8196CAF82E3DE117008482E3 /* CapsuleButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CAF72E3DE112008482E3 /* CapsuleButtonStyle.swift */; }; + 8196CAFA2E3DE355008482E3 /* View+CapsuleBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CAF92E3DE34E008482E3 /* View+CapsuleBackground.swift */; }; + 8196CAFC2E3DE5AA008482E3 /* PickAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CAFB2E3DE5A7008482E3 /* PickAny.swift */; }; + 8196CB072E3DF2AD008482E3 /* ListContentFilterAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CB062E3DF2A4008482E3 /* ListContentFilterAccessoryView.swift */; }; + 8196CB092E3DF4F2008482E3 /* ContentFilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CB082E3DF4F2008482E3 /* ContentFilterOption.swift */; }; + 8196CB142E3E5168008482E3 /* FilterResetButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CB132E3E5168008482E3 /* FilterResetButton.swift */; }; + 8196CB162E3E62CB008482E3 /* SelectAnySegmentControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196CB152E3E62CB008482E3 /* SelectAnySegmentControl.swift */; }; + 81AACF162E39232E000A2319 /* ToolbarSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81AACF152E39231D000A2319 /* ToolbarSetup.swift */; }; + 81C8451E2E47CA7A0065E647 /* NewSessionTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C8451D2E47CA7A0065E647 /* NewSessionTableCellView.swift */; }; + 81C845202E47DD7B0065E647 /* ViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C8451F2E47DD750065E647 /* ViewControllerWrapper.swift */; }; + 81CE115D2E3919ED002A0475 /* TahoeFeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE115C2E3919E6002A0475 /* TahoeFeatureFlag.swift */; }; + 81CE57982E43DA260006C9B9 /* NewExploreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57972E43DA260006C9B9 /* NewExploreViewModel.swift */; }; + 81CE579A2E43DA370006C9B9 /* NewExploreCategoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57992E43DA370006C9B9 /* NewExploreCategoryList.swift */; }; + 81CE579C2E43DA8F0006C9B9 /* NewExploreTabContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE579B2E43DA8F0006C9B9 /* NewExploreTabContentView.swift */; }; + 81CE579E2E43DADF0006C9B9 /* NewExploreTabDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE579D2E43DADF0006C9B9 /* NewExploreTabDetailView.swift */; }; + 81CE57A02E43DB590006C9B9 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE579F2E43DB530006C9B9 /* SessionListView.swift */; }; + 81CE57A22E43DB640006C9B9 /* SessionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57A12E43DB600006C9B9 /* SessionListViewModel.swift */; }; + 81CE57A42E43DC570006C9B9 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57A32E43DC4D0006C9B9 /* GlobalSearchCoordinator.swift */; }; + 81CE57A62E4484D60006C9B9 /* SessionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57A52E4484D60006C9B9 /* SessionItemView.swift */; }; + 81CE57A82E44850C0006C9B9 /* SessionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CE57A72E44850C0006C9B9 /* SessionItemViewModel.swift */; }; + 81E050552E3A854B007274FB /* NewSessionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E050542E3A854B007274FB /* NewSessionsTableViewController.swift */; }; + 81E172732E42469600BC1E5C /* NewSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E172722E42469100BC1E5C /* NewSessionDetailView.swift */; }; + 81E172792E42693200BC1E5C /* SessionCoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E172782E42692900BC1E5C /* SessionCoverView.swift */; }; + 81E1727B2E42695900BC1E5C /* DetailDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E1727A2E42695400BC1E5C /* DetailDescriptionView.swift */; }; + 81E1727D2E42698E00BC1E5C /* DetailRelatedSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E1727C2E42698800BC1E5C /* DetailRelatedSessionView.swift */; }; + 81F328792E4B59FE0077F682 /* NSView+Glass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F328782E4B59F90077F682 /* NSView+Glass.swift */; }; 9104BDFE2A25165A00860C08 /* Combine+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9104BDFD2A25165A00860C08 /* Combine+UI.swift */; }; 910637502E26B68700E917F0 /* PUIButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9106374F2E26B68700E917F0 /* PUIButtonView.swift */; }; 911C72C92A52169A00CB3757 /* CombineLatestMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = 911C72C82A52169A00CB3757 /* CombineLatestMany.swift */; }; @@ -314,6 +352,44 @@ 4DDF6A772177A00C008E5539 /* DownloadsManagementTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsManagementTableCellView.swift; sourceTree = ""; }; 4DF6641520C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionsTableViewController+SupportingTypesAndExtensions.swift"; sourceTree = ""; }; 557221032C190BC0002B42C9 /* OptionalToggleFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalToggleFilter.swift; sourceTree = ""; }; + 81057FF92E489E8C006ACCE7 /* InteractionEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEffects.swift; sourceTree = ""; }; + 81057FFB2E489F70006ACCE7 /* SessionPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPlayerView.swift; sourceTree = ""; }; + 810580002E495B00006ACCE7 /* NSView+BackgroundExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+BackgroundExtension.swift"; sourceTree = ""; }; + 8115CEA62E4B2C41001C0FE4 /* NSColor+AppearanceCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+AppearanceCustomization.swift"; sourceTree = ""; }; + 812EB5802E3E973F00FE869F /* NewTopicHeaderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTopicHeaderRow.swift; sourceTree = ""; }; + 812EB5822E3F59F300FE869F /* NewTranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTranscriptView.swift; sourceTree = ""; }; + 8181E2652E39F0710033126E /* ReplaceableSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceableSplitViewController.swift; sourceTree = ""; }; + 8181E2792E3A5E620033126E /* NewMainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMainWindowController.swift; sourceTree = ""; }; + 8181E27B2E3A634D0033126E /* WWDCCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WWDCCoordinator.swift; sourceTree = ""; }; + 8181E27D2E3A6CC20033126E /* NewAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAppCoordinator.swift; sourceTree = ""; }; + 8196CAF22E3D634A008482E3 /* GlobalSearchTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchTabState.swift; sourceTree = ""; }; + 8196CAF52E3DE09C008482E3 /* CapsuleToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleToggleStyle.swift; sourceTree = ""; }; + 8196CAF72E3DE112008482E3 /* CapsuleButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleButtonStyle.swift; sourceTree = ""; }; + 8196CAF92E3DE34E008482E3 /* View+CapsuleBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+CapsuleBackground.swift"; sourceTree = ""; }; + 8196CAFB2E3DE5A7008482E3 /* PickAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickAny.swift; sourceTree = ""; }; + 8196CB062E3DF2A4008482E3 /* ListContentFilterAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListContentFilterAccessoryView.swift; sourceTree = ""; }; + 8196CB082E3DF4F2008482E3 /* ContentFilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilterOption.swift; sourceTree = ""; }; + 8196CB132E3E5168008482E3 /* FilterResetButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterResetButton.swift; sourceTree = ""; }; + 8196CB152E3E62CB008482E3 /* SelectAnySegmentControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectAnySegmentControl.swift; sourceTree = ""; }; + 81AACF152E39231D000A2319 /* ToolbarSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarSetup.swift; sourceTree = ""; }; + 81C8451D2E47CA7A0065E647 /* NewSessionTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionTableCellView.swift; sourceTree = ""; }; + 81C8451F2E47DD750065E647 /* ViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerWrapper.swift; sourceTree = ""; }; + 81CE115C2E3919E6002A0475 /* TahoeFeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TahoeFeatureFlag.swift; sourceTree = ""; }; + 81CE57972E43DA260006C9B9 /* NewExploreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewExploreViewModel.swift; sourceTree = ""; }; + 81CE57992E43DA370006C9B9 /* NewExploreCategoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewExploreCategoryList.swift; sourceTree = ""; }; + 81CE579B2E43DA8F0006C9B9 /* NewExploreTabContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewExploreTabContentView.swift; sourceTree = ""; }; + 81CE579D2E43DADF0006C9B9 /* NewExploreTabDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewExploreTabDetailView.swift; sourceTree = ""; }; + 81CE579F2E43DB530006C9B9 /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; + 81CE57A12E43DB600006C9B9 /* SessionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListViewModel.swift; sourceTree = ""; }; + 81CE57A32E43DC4D0006C9B9 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchCoordinator.swift; sourceTree = ""; }; + 81CE57A52E4484D60006C9B9 /* SessionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionItemView.swift; sourceTree = ""; }; + 81CE57A72E44850C0006C9B9 /* SessionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionItemViewModel.swift; sourceTree = ""; }; + 81E050542E3A854B007274FB /* NewSessionsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionsTableViewController.swift; sourceTree = ""; }; + 81E172722E42469100BC1E5C /* NewSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionDetailView.swift; sourceTree = ""; }; + 81E172782E42692900BC1E5C /* SessionCoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCoverView.swift; sourceTree = ""; }; + 81E1727A2E42695400BC1E5C /* DetailDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailDescriptionView.swift; sourceTree = ""; }; + 81E1727C2E42698800BC1E5C /* DetailRelatedSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailRelatedSessionView.swift; sourceTree = ""; }; + 81F328782E4B59F90077F682 /* NSView+Glass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Glass.swift"; sourceTree = ""; }; 91037C8C2A32AF62009AF15E /* Transcripts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Transcripts; path = Packages/Transcripts; sourceTree = ""; }; 9104BDFD2A25165A00860C08 /* Combine+UI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Combine+UI.swift"; sourceTree = ""; }; 9106374F2E26B68700E917F0 /* PUIButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUIButtonView.swift; sourceTree = ""; }; @@ -579,6 +655,85 @@ name = Downloads; sourceTree = ""; }; + 8196CAF42E3DE04C008482E3 /* Search */ = { + isa = PBXGroup; + children = ( + 8196CAF52E3DE09C008482E3 /* CapsuleToggleStyle.swift */, + 8196CAF72E3DE112008482E3 /* CapsuleButtonStyle.swift */, + 8196CAF92E3DE34E008482E3 /* View+CapsuleBackground.swift */, + 8196CAFB2E3DE5A7008482E3 /* PickAny.swift */, + 8196CB152E3E62CB008482E3 /* SelectAnySegmentControl.swift */, + 8196CB132E3E5168008482E3 /* FilterResetButton.swift */, + 8196CB062E3DF2A4008482E3 /* ListContentFilterAccessoryView.swift */, + ); + path = Search; + sourceTree = ""; + }; + 81CE115B2E3919DC002A0475 /* Tahoe */ = { + isa = PBXGroup; + children = ( + 81CE57962E43DA150006C9B9 /* Explore */, + 81CE57952E43DA040006C9B9 /* SessionList */, + 81E172762E4268E900BC1E5C /* SessionDetail */, + 81CE115C2E3919E6002A0475 /* TahoeFeatureFlag.swift */, + 81AACF152E39231D000A2319 /* ToolbarSetup.swift */, + 8181E2652E39F0710033126E /* ReplaceableSplitViewController.swift */, + 8181E2792E3A5E620033126E /* NewMainWindowController.swift */, + 8181E27D2E3A6CC20033126E /* NewAppCoordinator.swift */, + 81E050542E3A854B007274FB /* NewSessionsTableViewController.swift */, + 81057FF92E489E8C006ACCE7 /* InteractionEffects.swift */, + 8196CAF22E3D634A008482E3 /* GlobalSearchTabState.swift */, + 81CE57A32E43DC4D0006C9B9 /* GlobalSearchCoordinator.swift */, + ); + path = Tahoe; + sourceTree = ""; + }; + 81CE57952E43DA040006C9B9 /* SessionList */ = { + isa = PBXGroup; + children = ( + 812EB5802E3E973F00FE869F /* NewTopicHeaderRow.swift */, + 81CE579F2E43DB530006C9B9 /* SessionListView.swift */, + 81CE57A12E43DB600006C9B9 /* SessionListViewModel.swift */, + 81CE57A72E44850C0006C9B9 /* SessionItemViewModel.swift */, + 81CE57A52E4484D60006C9B9 /* SessionItemView.swift */, + 81C8451D2E47CA7A0065E647 /* NewSessionTableCellView.swift */, + 81C8451F2E47DD750065E647 /* ViewControllerWrapper.swift */, + ); + path = SessionList; + sourceTree = ""; + }; + 81CE57962E43DA150006C9B9 /* Explore */ = { + isa = PBXGroup; + children = ( + 81CE57992E43DA370006C9B9 /* NewExploreCategoryList.swift */, + 81CE579D2E43DADF0006C9B9 /* NewExploreTabDetailView.swift */, + 81CE579B2E43DA8F0006C9B9 /* NewExploreTabContentView.swift */, + 81CE57972E43DA260006C9B9 /* NewExploreViewModel.swift */, + ); + path = Explore; + sourceTree = ""; + }; + 81E172762E4268E900BC1E5C /* SessionDetail */ = { + isa = PBXGroup; + children = ( + 81E172722E42469100BC1E5C /* NewSessionDetailView.swift */, + 812EB5822E3F59F300FE869F /* NewTranscriptView.swift */, + 81E172772E42691000BC1E5C /* Components */, + ); + path = SessionDetail; + sourceTree = ""; + }; + 81E172772E42691000BC1E5C /* Components */ = { + isa = PBXGroup; + children = ( + 81E172782E42692900BC1E5C /* SessionCoverView.swift */, + 81E1727A2E42695400BC1E5C /* DetailDescriptionView.swift */, + 81E1727C2E42698800BC1E5C /* DetailRelatedSessionView.swift */, + 81057FFB2E489F70006ACCE7 /* SessionPlayerView.swift */, + ); + path = Components; + sourceTree = ""; + }; DD0159CD1ED0CD1D00F980F1 /* Preferences */ = { isa = PBXGroup; children = ( @@ -626,6 +781,7 @@ DD36A4AE1E478C6A00B2EA88 /* WWDC */ = { isa = PBXGroup; children = ( + 81CE115B2E3919DC002A0475 /* Tahoe */, DD1879C224868E5700164AD1 /* WWDCDebug_iCloud.entitlements */, DD1879C324868EF700164AD1 /* WWDCRelease_iCloud.entitlements */, DDCE7ECF1EA7A1D300C7A3CA /* WWDC-Bridging-Header.h */, @@ -654,6 +810,7 @@ F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */, DD36A4AF1E478C6A00B2EA88 /* AppDelegate.swift */, DDCE7ED81EA7A86600C7A3CA /* AppCoordinator.swift */, + 8181E27B2E3A634D0033126E /* WWDCCoordinator.swift */, F4CCF941265ED24500A69E62 /* AppCommandsReceiver.swift */, DDF32EB81EBE65B50028E39D /* AppCoordinator+Shelf.swift */, DD0159A81ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift */, @@ -686,6 +843,7 @@ DD36A4C01E478CF500B2EA88 /* Views */ = { isa = PBXGroup; children = ( + 8196CAF42E3DE04C008482E3 /* Search */, DDF32EB11EBE34E10028E39D /* Base */, DDF32EB01EBE34CE0028E39D /* TableView */, F4FB069D2A2148D800799F84 /* ExploreTab */, @@ -869,6 +1027,7 @@ DDEDFCF01ED927A4002477C8 /* ToggleFilter.swift */, DDEDFCF21ED92F2A002477C8 /* TextualFilter.swift */, 4D9EE96324BCE097001B1720 /* FilterState.swift */, + 8196CB082E3DF4F2008482E3 /* ContentFilterOption.swift */, ); name = Search; sourceTree = ""; @@ -1086,7 +1245,10 @@ F4FE95AB2C08FD6E005E76EC /* AVPlayer+Layout.swift */, 4DBFA4D920E160CB00BDF34B /* AVAsset+AsyncHelpers.swift */, DDF721BB1ECA12A40054C503 /* NSEvent+ForceTouch.swift */, + 810580002E495B00006ACCE7 /* NSView+BackgroundExtension.swift */, + 81F328782E4B59F90077F682 /* NSView+Glass.swift */, DDF721BD1ECA12A40054C503 /* String+CMTime.swift */, + 8115CEA62E4B2C41001C0FE4 /* NSColor+AppearanceCustomization.swift */, F4F189792C0775C5006EA9A2 /* NumericContentTransition.swift */, F422B8AF2C079DEA00C4B337 /* PUISettings.swift */, ); @@ -1502,6 +1664,8 @@ F486B30A2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift in Sources */, DDB28F8E1EAD257B0077703F /* PlaybackViewModel.swift in Sources */, DD4648491ECA5EC0005C57C6 /* NSToolbarItemViewer+Overrides.m in Sources */, + 8181E2662E39F0770033126E /* ReplaceableSplitViewController.swift in Sources */, + 81CE579E2E43DADF0006C9B9 /* NewExploreTabDetailView.swift in Sources */, DDF32EAB1EBE2E240028E39D /* WWDCTableRowView.swift in Sources */, DD7E2902247FEA3900A58370 /* EventHeroViewController.swift in Sources */, F486B30C2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift in Sources */, @@ -1520,6 +1684,7 @@ DDB28F861EAD20A10077703F /* UIDebugger.m in Sources */, F4CCF942265ED24500A69E62 /* AppCommandsReceiver.swift in Sources */, F486B30B2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift in Sources */, + 8196CAF82E3DE117008482E3 /* CapsuleButtonStyle.swift in Sources */, DDB3529A1EC8AB2800254815 /* WWDCImageView.swift in Sources */, 557221042C190BC0002B42C9 /* OptionalToggleFilter.swift in Sources */, DD3D14F62486C91F00FCBBBD /* ClipRenderer.swift in Sources */, @@ -1528,8 +1693,12 @@ DD0159CF1ED0CD3A00F980F1 /* PreferencesWindowController.swift in Sources */, F44C823C2A22B90600FDE980 /* RemoteImage.swift in Sources */, F44C82332A22879000FDE980 /* TitleBarBlurFadeView.swift in Sources */, + 81C8451E2E47CA7A0065E647 /* NewSessionTableCellView.swift in Sources */, 911C72C92A52169A00CB3757 /* CombineLatestMany.swift in Sources */, + 81E172732E42469600BC1E5C /* NewSessionDetailView.swift in Sources */, F4D0F0362A2012C700C74B50 /* VisualEffectDebugger.m in Sources */, + 8196CB162E3E62CB008482E3 /* SelectAnySegmentControl.swift in Sources */, + 81CE57A22E43DB640006C9B9 /* SessionListViewModel.swift in Sources */, DDC678191EDB2CD300A4E19C /* ActionLabel.swift in Sources */, DD876D351EC2A7410058EE3B /* ImageDownloadCenter.swift in Sources */, DD6E06F61EDBC379000EAEA4 /* WWDCBottomBorderView.swift in Sources */, @@ -1538,6 +1707,8 @@ F44C82352A22921300FDE980 /* ExploreTabProvider.swift in Sources */, F486B3122C0E69E60066749F /* URLSessionTask+Media.swift in Sources */, DDF32EB31EBE5C4D0028E39D /* SessionActionsViewController.swift in Sources */, + 81CE579A2E43DA370006C9B9 /* NewExploreCategoryList.swift in Sources */, + 81AACF162E39232E000A2319 /* ToolbarSetup.swift in Sources */, DDA7B7352484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.m in Sources */, F46E0AE72C0E7B780077A5E0 /* DownloadedContentMonitor.swift in Sources */, F4578D5B2A2659F0005B311A /* VideoPlayer.swift in Sources */, @@ -1545,11 +1716,15 @@ DDB28F931EAD48D70077703F /* UserActivityRepresentable.swift in Sources */, 4DA25DC821063CD500762BBD /* TitleBarViewController.swift in Sources */, 4D5EB0F820598E6000D4BC52 /* SessionRowProvider.swift in Sources */, + 8196CB092E3DF4F2008482E3 /* ContentFilterOption.swift in Sources */, DD7E28FF247FE69400A58370 /* ScheduleContainerViewController.swift in Sources */, F4FB06C12A2178C000799F84 /* WWDCWindowContentViewController.swift in Sources */, DDEDFCF11ED927A4002477C8 /* ToggleFilter.swift in Sources */, F4578D592A2659C5005B311A /* LiveStreamOverlay.swift in Sources */, F486B3092C0E69E60066749F /* MediaDownloadManager.swift in Sources */, + 81CE115D2E3919ED002A0475 /* TahoeFeatureFlag.swift in Sources */, + 8196CAF62E3DE0A2008482E3 /* CapsuleToggleStyle.swift in Sources */, + 81CE57A42E43DC570006C9B9 /* GlobalSearchCoordinator.swift in Sources */, 9104BDFE2A25165A00860C08 /* Combine+UI.swift in Sources */, DDFA10BF1EBEAAAD001DCF66 /* DownloadManager.swift in Sources */, DD0159A71ECFE26200F980F1 /* DeepLink.swift in Sources */, @@ -1561,17 +1736,25 @@ DDB352821EC7C55300254815 /* DateProvider.swift in Sources */, F486B3082C0E69E60066749F /* FSMediaDownloadMetadataStore.swift in Sources */, DDC678221EDB956700A4E19C /* BookmarkViewController.swift in Sources */, + 8181E27C2E3A63550033126E /* WWDCCoordinator.swift in Sources */, DD7F386A1EABE996002D8C00 /* SessionTableCellView.swift in Sources */, 4DF6641620C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift in Sources */, DD36A4B21E478C6A00B2EA88 /* SessionsSplitViewController.swift in Sources */, + 81E1727D2E42698E00BC1E5C /* DetailRelatedSessionView.swift in Sources */, + 81E1727B2E42695900BC1E5C /* DetailDescriptionView.swift in Sources */, DDB352841EC7C74C00254815 /* LiveObserver.swift in Sources */, + 8196CAF32E3D6353008482E3 /* GlobalSearchTabState.swift in Sources */, DDDF807420BA3124007284F8 /* ExploreViewController.swift in Sources */, DDF32EAF1EBE34CB0028E39D /* TitleTableCellView.swift in Sources */, + 81CE579C2E43DA8F0006C9B9 /* NewExploreTabContentView.swift in Sources */, + 81CE57A82E44850C0006C9B9 /* SessionItemViewModel.swift in Sources */, DD7F385F1EABD631002D8C00 /* WWDCTabViewController.swift in Sources */, F474DEC926737EFA00B28B31 /* SharePlayManager.swift in Sources */, 4D66CA50217E2C800006A8C9 /* DownloadsManagementTableRowView.swift in Sources */, + 8181E27E2E3A6CC70033126E /* NewAppCoordinator.swift in Sources */, F486B3072C0E69E60066749F /* MediaDownloadProtocols.swift in Sources */, DDCE7ED91EA7A86600C7A3CA /* AppCoordinator.swift in Sources */, + 81057FFA2E489E8F006ACCE7 /* InteractionEffects.swift in Sources */, DD0159D11ED0CEF500F980F1 /* PreferencesCoordinator.swift in Sources */, DDEDFCF31ED92F2A002477C8 /* TextualFilter.swift in Sources */, F486B3102C0E69E60066749F /* String+Error.swift in Sources */, @@ -1580,7 +1763,10 @@ DD7F38621EABD6CF002D8C00 /* SessionsTableViewController.swift in Sources */, DD78588124C3594B008C1C22 /* SlowMigrationView.swift in Sources */, DDA60E1320A90655002EECF5 /* SessionCellView.swift in Sources */, + 8181E27A2E3A5E6E0033126E /* NewMainWindowController.swift in Sources */, + 8196CAFA2E3DE355008482E3 /* View+CapsuleBackground.swift in Sources */, DD7E2907248000AA00A58370 /* FullBleedImageView.swift in Sources */, + 8196CB142E3E5168008482E3 /* FilterResetButton.swift in Sources */, DDF32EAD1EBE2F9F0028E39D /* SessionRow.swift in Sources */, F44C823A2A22B8DD00FDE980 /* ExploreTabContent.swift in Sources */, 4D0E806F217A2D6E00B24237 /* DownloadManager+SupportingTypesAndExtensions.swift in Sources */, @@ -1594,16 +1780,20 @@ DD6E06FC1EDBCA7E000EAEA4 /* TranscriptTableCellView.swift in Sources */, 4D66CA52217E2C9B0006A8C9 /* DownloadsManagementTableView.swift in Sources */, DD3D14F42486C90B00FCBBBD /* ClipSharingViewController.swift in Sources */, + 81CE57982E43DA260006C9B9 /* NewExploreViewModel.swift in Sources */, DDF32EB91EBE65B50028E39D /* AppCoordinator+Shelf.swift in Sources */, DDDF808020BA53A4007284F8 /* FlippedClipView.swift in Sources */, + 81CE57A62E4484D60006C9B9 /* SessionItemView.swift in Sources */, DDB3529C1EC8AB5D00254815 /* WWDCLayer.swift in Sources */, DDA7B7142482A4A500F86668 /* TranscriptSearchController.swift in Sources */, DD7F38651EABD6DF002D8C00 /* SessionDetailsViewController.swift in Sources */, F4A882882673AD2D00BAB7F5 /* SharePlayStatusView.swift in Sources */, DD34A79B1EC3CD5900E0B575 /* Constants.swift in Sources */, + 81CE57A02E43DB590006C9B9 /* SessionListView.swift in Sources */, 4DA9C4D120EC098800710354 /* DownloadsManagementViewController.swift in Sources */, DD90CDCD1ED7A5ED00CADE86 /* SearchCoordinator.swift in Sources */, DDB28F6F1EACFCDB0077703F /* VibrantButton.swift in Sources */, + 81057FFC2E489F74006ACCE7 /* SessionPlayerView.swift in Sources */, 914367202A4C6B0E004E4392 /* Sequence+GroupedBy.swift in Sources */, F486B30D2C0E69E60066749F /* Bundle+URLSessionID.swift in Sources */, 4D7482CA20FF735D008D156C /* WWDCWindowController.swift in Sources */, @@ -1617,13 +1807,17 @@ DDDF807E20BA4FFA007284F8 /* WWDCHorizontalScrollView.swift in Sources */, F4F2792A2C0F777200A029A3 /* DownloadManagerView.swift in Sources */, DD7F387D1EAC113A002D8C00 /* WWDCTextField.swift in Sources */, + 81E050552E3A854B007274FB /* NewSessionsTableViewController.swift in Sources */, DDB352801EC7C4CA00254815 /* Arguments.swift in Sources */, 4D9EE96424BCE097001B1720 /* FilterState.swift in Sources */, DD7F387A1EAC0CE3002D8C00 /* SessionSummaryViewController.swift in Sources */, + 8196CB072E3DF2AD008482E3 /* ListContentFilterAccessoryView.swift in Sources */, + 81C845202E47DD7B0065E647 /* ViewControllerWrapper.swift in Sources */, F474DECD2673801500B28B31 /* WatchWWDCActivity.swift in Sources */, DD382B7E1EAC3565009760C4 /* TabItemView.swift in Sources */, DDCE7ECD1EA7A0F800C7A3CA /* MainWindowController.swift in Sources */, F4A882842673AC8500BAB7F5 /* TitleBarButtonsViewController.swift in Sources */, + 8196CAFC2E3DE5AA008482E3 /* PickAny.swift in Sources */, DDC6780F1EDB253D00A4E19C /* AboutWindowController.swift in Sources */, 01B3EB4A1EEDD23100DE1003 /* AppCoordinator+SessionTableViewContextMenuActions.swift in Sources */, DD2E27881EAC2CCB0009D7B6 /* ShelfView.swift in Sources */, @@ -1635,7 +1829,10 @@ DD4873D320AE5FF3005033CE /* AppCoordinator+RelatedSessions.swift in Sources */, DDEDFCEF1ED92785002477C8 /* MultipleChoiceFilter.swift in Sources */, F486B3112C0E69E60066749F /* URL+FileHelpers.swift in Sources */, + 812EB5832E3F59FC00FE869F /* NewTranscriptView.swift in Sources */, + 81E172792E42693200BC1E5C /* SessionCoverView.swift in Sources */, DDEA85FB1EB52AB5002AE0EB /* VideoPlayerViewController.swift in Sources */, + 812EB5812E3E973F00FE869F /* NewTopicHeaderRow.swift in Sources */, DDA50E3524AA5090007C77C6 /* Boot.swift in Sources */, DDC6781B1EDB629C00A4E19C /* GeneralPreferencesViewController.swift in Sources */, DD4648471ECA5947005C57C6 /* WWDCWindow.swift in Sources */, @@ -1698,6 +1895,7 @@ DDC678241EDBA25A00A4E19C /* PUIAnnotationWindow.swift in Sources */, DDF721C71ECA12A40054C503 /* PUIDetachedPlaybackStatusViewController.swift in Sources */, DDF721E11ECA12A40054C503 /* PUITimelineView.swift in Sources */, + 810580012E495B09006ACCE7 /* NSView+BackgroundExtension.swift in Sources */, DDF721DF1ECA12A40054C503 /* PUIPlayerWindow.swift in Sources */, DDF721CB1ECA12A40054C503 /* Speeds.swift in Sources */, DDF721DB1ECA12A40054C503 /* PUIBoringLayer.swift in Sources */, @@ -1708,6 +1906,8 @@ DDEA82FC20909A4C00D36BE0 /* PUIRemoteCommandCoordinator.swift in Sources */, DDF721D91ECA12A40054C503 /* String+CMTime.swift in Sources */, DDF721DC1ECA12A40054C503 /* PUIBufferLayer.swift in Sources */, + 8115CEA72E4B2C4F001C0FE4 /* NSColor+AppearanceCustomization.swift in Sources */, + 81F328792E4B59FE0077F682 /* NSView+Glass.swift in Sources */, 910637502E26B68700E917F0 /* PUIButtonView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/WWDC/AppCoordinator+Bookmarks.swift b/WWDC/AppCoordinator+Bookmarks.swift index 6e39a949..6ce15d2f 100644 --- a/WWDC/AppCoordinator+Bookmarks.swift +++ b/WWDC/AppCoordinator+Bookmarks.swift @@ -12,7 +12,7 @@ import PlayerUI import RealmSwift import ConfCore -extension AppCoordinator: PUITimelineDelegate, VideoPlayerViewControllerDelegate { +extension WWDCCoordinator/*: PUITimelineDelegate, VideoPlayerViewControllerDelegate*/ { func createBookmark(at timecode: Double, with snapshot: NSImage?) { guard let session = currentPlayerController?.sessionViewModel.session else { return } diff --git a/WWDC/AppCoordinator+RelatedSessions.swift b/WWDC/AppCoordinator+RelatedSessions.swift index 24d22227..78bb76de 100644 --- a/WWDC/AppCoordinator+RelatedSessions.swift +++ b/WWDC/AppCoordinator+RelatedSessions.swift @@ -9,7 +9,7 @@ import Cocoa import ConfCore -extension AppCoordinator: RelatedSessionsDelegate { +extension WWDCCoordinator/*: RelatedSessionsDelegate*/ { func relatedSessions(_ relatedSessions: RelatedSessionsViewModel, didSelectSession viewModel: SessionViewModel) { selectSessionOnAppropriateTab(with: viewModel) diff --git a/WWDC/AppCoordinator+SessionActions.swift b/WWDC/AppCoordinator+SessionActions.swift index 504dfb71..2c6aa0e3 100644 --- a/WWDC/AppCoordinator+SessionActions.swift +++ b/WWDC/AppCoordinator+SessionActions.swift @@ -13,9 +13,18 @@ import PlayerUI import EventKit import OSLog -extension AppCoordinator: SessionActionsDelegate { +private enum SessionActionChoice: Int { + case yes = 1001 + case no = 1000 +} + +private enum CalendarChoice: NSApplication.ModalResponse.RawValue { + case removeCalendar = 1000 + case cancel = 1001 +} + +extension WWDCCoordinator/*: SessionActionsDelegate */{ - @MainActor func sessionActionsDidSelectCancelDownload(_ sender: NSView?) { guard let viewModel = activeTabSelectedSessionViewModel else { return } @@ -38,7 +47,6 @@ extension AppCoordinator: SessionActionsDelegate { NSWorkspace.shared.open(url) } - @MainActor func sessionActionsDidSelectDownload(_ sender: NSView?) { guard let viewModel = activeTabSelectedSessionViewModel else { return } @@ -56,12 +64,7 @@ extension AppCoordinator: SessionActionsDelegate { alert.addButton(withTitle: "No") alert.addButton(withTitle: "Yes") - enum Choice: Int { - case yes = 1001 - case no = 1000 - } - - guard let choice = Choice(rawValue: alert.runModal().rawValue) else { return } + guard let choice = SessionActionChoice(rawValue: alert.runModal().rawValue) else { return } switch choice { case .yes: @@ -76,7 +79,7 @@ extension AppCoordinator: SessionActionsDelegate { } func sessionActionsDidSelectShare(_ sender: NSView?) { - guard let sender = sender else { return } + guard let sender = sender ?? getNSViewUnderTheMouse() else { return } guard let viewModel = activeTabSelectedSessionViewModel else { return } guard let webpageAsset = viewModel.session.asset(ofType: .webpage) else { return } @@ -89,6 +92,25 @@ extension AppCoordinator: SessionActionsDelegate { } func sessionActionsDidSelectShareClip(_ sender: NSView?) { + showClipUI() + } + + private func getNSViewUnderTheMouse() -> NSView? { + let mouseLocation = NSEvent.mouseLocation + guard + let window = windowController.window, + let vc = windowController.contentViewController + else { + return nil + } + let mouseLocationInWindow = window.convertPoint(fromScreen: mouseLocation) + let mouseLocationInView = vc.view.convert(mouseLocationInWindow, from: nil) + return vc.view.hitTest(mouseLocationInView) + } +} + +extension AppCoordinator { + func showClipUI() { switch activeTab { case .schedule: scheduleController.splitViewController.detailViewController.shelfController.showClipUI() @@ -98,7 +120,6 @@ extension AppCoordinator: SessionActionsDelegate { break } } - } final class PickerDelegate: NSObject, NSSharingServicePickerDelegate, Logging { @@ -146,8 +167,8 @@ extension Storage { // MARK: - Calendar Integration -extension AppCoordinator { - @objc func sessionActionsDidSelectCalendar(_ sender: NSView?) { +extension WWDCCoordinator { + func sessionActionsDidSelectCalendar(_ sender: NSView?) { guard let viewModel = activeTabSelectedSessionViewModel else { return } Task { @MainActor in @@ -196,12 +217,7 @@ extension AppCoordinator { alert.addButton(withTitle: "Cancel") alert.window.center() - enum Choice: NSApplication.ModalResponse.RawValue { - case removeCalendar = 1000 - case cancel = 1001 - } - - guard let choice = Choice(rawValue: alert.runModal().rawValue) else { return } + guard let choice = CalendarChoice(rawValue: alert.runModal().rawValue) else { return } switch choice { case .removeCalendar: diff --git a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift index 61ea3d5e..c75aca1d 100644 --- a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift +++ b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift @@ -11,7 +11,12 @@ import RealmSwift import ConfCore import PlayerUI -extension AppCoordinator: SessionsTableViewControllerDelegate { +private enum SessionChoice: Int { + case yes = 1001 + case no = 1000 +} + +extension WWDCCoordinator/*: SessionsTableViewControllerDelegate */{ func sessionTableViewContextMenuActionWatch(viewModels: [SessionViewModel]) { storage.modify(viewModels.map({ $0.session })) { sessions in @@ -35,7 +40,6 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { storage.setFavorite(false, onSessionsWithIDs: viewModels.map({ $0.session.identifier })) } - @MainActor func sessionTableViewContextMenuActionDownload(viewModels: [SessionViewModel]) { if viewModels.count > 5 { // asking to download many videos, warn @@ -47,12 +51,7 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { alert.addButton(withTitle: "No") alert.addButton(withTitle: "Yes") - enum Choice: Int { - case yes = 1001 - case no = 1000 - } - - guard let choice = Choice(rawValue: alert.runModal().rawValue) else { return } + guard let choice = SessionChoice(rawValue: alert.runModal().rawValue) else { return } guard case .yes = choice else { return } } @@ -60,21 +59,18 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { MediaDownloadManager.shared.download(viewModels.map(\.session)) } - @MainActor func sessionTableViewContextMenuActionCancelDownload(viewModels: [SessionViewModel]) { let cancellableDownloads = viewModels.map(\.session).filter { MediaDownloadManager.shared.isDownloadingMedia(for: $0) } MediaDownloadManager.shared.cancelDownload(for: cancellableDownloads) } - @MainActor func sessionTableViewContextMenuActionRemoveDownload(viewModels: [SessionViewModel]) { let deletableDownloads = viewModels.map(\.session).filter { MediaDownloadManager.shared.hasDownloadedMedia(for: $0) } MediaDownloadManager.shared.delete(deletableDownloads) } - @MainActor func sessionTableViewContextMenuActionRevealInFinder(viewModels: [SessionViewModel]) { guard let firstSession = viewModels.first?.session else { return } guard let localURL = MediaDownloadManager.shared.downloadedFileURL(for: firstSession) else { return } diff --git a/WWDC/AppCoordinator+Shelf.swift b/WWDC/AppCoordinator+Shelf.swift index dffc9163..c2835d3a 100644 --- a/WWDC/AppCoordinator+Shelf.swift +++ b/WWDC/AppCoordinator+Shelf.swift @@ -12,9 +12,8 @@ import ConfCore import PlayerUI import CoreMedia -extension AppCoordinator: ShelfViewControllerDelegate { - - private func shelf(for tab: MainWindowTab) -> ShelfViewController? { +extension AppCoordinator { + func shelf(for tab: MainWindowTab) -> ShelfViewController? { var shelfViewController: ShelfViewController? switch tab { @@ -28,6 +27,13 @@ extension AppCoordinator: ShelfViewControllerDelegate { return shelfViewController } + func select(session: any SessionIdentifiable, removingFiltersIfNeeded: Bool) { + currentListController?.select(session: session, removingFiltersIfNeeded: removingFiltersIfNeeded) + } +} + +extension WWDCCoordinator/*: ShelfViewControllerDelegate*/ { + func updateShelfBasedOnSelectionChange() { guard !isTransitioningPlayerContext else { return } @@ -62,11 +68,11 @@ extension AppCoordinator: ShelfViewControllerDelegate { // Always reveal the tab to avoid the case of the session selected // on tab that isn't the player owner tab - tabController.activeTab = playerOwnerTab + tabController.setActiveTab(playerOwnerTab) // Reveal the session if playerOwnerSessionIdentifier != activeTabSelectedSessionViewModel?.identifier { - currentListController?.select(session: SessionIdentifier(identifier)) + select(session: SessionIdentifier(identifier), removingFiltersIfNeeded: true) } // Show the container @@ -116,7 +122,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { shelfController.playButton.isHidden = true shelfController.playerContainer.isHidden = false - + currentPlayerController?.playerView.resumeDetachedStatusIfNeeded() } catch { WWDCAlert.show(with: error) } @@ -150,30 +156,16 @@ extension AppCoordinator: ShelfViewControllerDelegate { } } + @MainActor private var playerTouchBarContainer: MainWindowController? { return currentPlayerController?.view.window?.windowController as? MainWindowController } + @MainActor private func attachPlayerToShelf(_ shelf: ShelfViewController) { guard let playerController = currentPlayerController else { return } - let playerContainer = shelf.playerContainer - - // Already attached - guard playerController.view.superview != playerContainer else { return } - - playerController.view.frame = playerContainer.bounds - playerController.view.alphaValue = 0 - playerController.view.isHidden = false - - playerController.view.translatesAutoresizingMaskIntoConstraints = false - - playerContainer.addSubview(playerController.view) - playerContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) - - playerContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) - - playerController.view.alphaValue = 1 + shelf.addPlayerViewIfNeeded(playerController) playerTouchBarContainer?.touchBarProvider = playerController.playerView } diff --git a/WWDC/AppCoordinator+UserActivity.swift b/WWDC/AppCoordinator+UserActivity.swift index 86834ff6..762b4bd6 100644 --- a/WWDC/AppCoordinator+UserActivity.swift +++ b/WWDC/AppCoordinator+UserActivity.swift @@ -11,7 +11,7 @@ import RealmSwift import ConfCore import PlayerUI -extension AppCoordinator { +extension WWDCCoordinator { func updateCurrentActivity(with item: UserActivityRepresentable?) { guard let item = item else { diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 54d33741..1319dfd1 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -14,10 +14,10 @@ import PlayerUI import OSLog import AVFoundation -final class AppCoordinator: Logging, Signposting { +final class AppCoordinator: WWDCCoordinator { - static let log = makeLogger() - static let signposter: OSSignposter = makeSignposter() + nonisolated static let log = makeLogger() + nonisolated static let signposter: OSSignposter = makeSignposter() private lazy var cancellables = Set() @@ -27,7 +27,7 @@ final class AppCoordinator: Logging, Signposting { var syncEngine: SyncEngine // - Top level controllers - var windowController: MainWindowController + var windowController: WWDCWindowControllerObject var tabController: WWDCTabViewController var searchCoordinator: SearchCoordinator @@ -67,7 +67,7 @@ final class AppCoordinator: Logging, Signposting { } } - var exploreTabLiveSession: some Publisher { + var exploreTabLiveSession: AnyPublisher { let liveInstances = storage.realm.objects(SessionInstance.self) .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") .sorted(byKeyPath: "startTime", ascending: false) @@ -76,6 +76,7 @@ final class AppCoordinator: Logging, Signposting { .map({ $0.toArray().first?.session }) .map({ SessionViewModel(session: $0, instance: $0?.instances.first, track: nil, style: .schedule) }) .replaceErrorWithEmpty() + .eraseToAnyPublisher() } /// The session that is currently selected on the videos tab (observable) @@ -265,7 +266,17 @@ final class AppCoordinator: Logging, Signposting { activeTabSelectedSessionViewModel = nil } + // pip detail view controller if needed updateShelfBasedOnSelectionChange() + // then update with newest view model + switch activeTab { + case .schedule: + scheduleController.splitViewController.detailViewController.viewModel = activeTabSelectedSessionViewModel + case .videos: + videosController.detailViewController.viewModel = activeTabSelectedSessionViewModel + default: + break + } updateCurrentActivity(with: activeTabSelectedSessionViewModel) } .store(in: &cancellables) @@ -503,7 +514,9 @@ final class AppCoordinator: Logging, Signposting { activityScheduler.repeats = true activityScheduler.qualityOfService = .utility activityScheduler.schedule { [weak self] completion in - self?.refresh(self?.autorefreshActivity) + DispatchQueue.main.async { + self?.refresh(self?.autorefreshActivity) + } completion(.finished) } diff --git a/WWDC/AppDelegate.swift b/WWDC/AppDelegate.swift index 4c571e9a..3daf72a2 100644 --- a/WWDC/AppDelegate.swift +++ b/WWDC/AppDelegate.swift @@ -25,10 +25,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { private lazy var commandsReceiver = AppCommandsReceiver() - private(set) var coordinator: AppCoordinator? { + private(set) var coordinator: (any WWDCCoordinator)? { didSet { if coordinator != nil { - openPendingDeepLinkIfNeeded() + DispatchQueue.main.async { + self.openPendingDeepLinkIfNeeded() + } } } } @@ -36,7 +38,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { func applicationWillFinishLaunching(_ notification: Notification) { NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(handleURLEvent(_:replyEvent:)), forEventClass: UInt32(kInternetEventClass), andEventID: UInt32(kAEGetURL)) - NSApplication.shared.appearance = NSAppearance(named: .darkAqua) +// NSApplication.shared.appearance = NSAppearance(named: .darkAqua) #if ICLOUD ConfCoreCapabilities.isCloudKitEnabled = true @@ -85,6 +87,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { } ImageDownloadCenter.shared.deleteLegacyImageCacheIfNeeded() + // setup liquid glass feature settings + if TahoeFeatureFlag.isLiquidGlassAvailable { + guard + let aboutMenu = NSApp.menu?.items.first?.submenu, + let firstSeparatorIndex = aboutMenu.items.firstIndex(where: { $0.isSeparatorItem }), // under About + aboutMenu.items.count > firstSeparatorIndex + 1 + else { + return + } + + let liquidGlassItem = NSMenuItem(title: "Try Liquid Glass", action: #selector(AppDelegate.tryLiquidGlass(_:)), keyEquivalent: "") + liquidGlassItem.indentationLevel = 1 + liquidGlassItem.state = TahoeFeatureFlag.isLiquidGlassEnabled ? .on : .mixed + aboutMenu.insertItem(liquidGlassItem, at: firstSeparatorIndex + 1) + } } private var storage: Storage? @@ -95,11 +112,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { self.storage = storage self.syncEngine = syncEngine - coordinator = AppCoordinator( - windowController: MainWindowController(), - storage: storage, - syncEngine: syncEngine - ) + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + let newAppCoordinator = NewAppCoordinator( + windowController: NewMainWindowController(), + storage: storage, + syncEngine: syncEngine + ) + coordinator = newAppCoordinator + newAppCoordinator.startup() + } else { + coordinator = AppCoordinator( + windowController: MainWindowController(), + storage: storage, + syncEngine: syncEngine + ) + } } private func handleBootstrapError(_ error: Boot.BootstrapError) { @@ -186,7 +213,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { private var pendingDeepLink: DeepLink? - private func openDeepLink(_ link: DeepLink) { + @MainActor private func openDeepLink(_ link: DeepLink) { guard let coordinator = coordinator else { pendingDeepLink = link return @@ -195,7 +222,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { coordinator.handle(link: link) } - private func openPendingDeepLinkIfNeeded() { + @MainActor private func openPendingDeepLinkIfNeeded() { guard let deepLink = pendingDeepLink else { return } coordinator?.handle(link: deepLink) @@ -221,6 +248,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { coordinator?.refresh(sender) } + @objc private func tryLiquidGlass(_ sender: NSMenuItem) { + guard TahoeFeatureFlag.isLiquidGlassAvailable else { + return + } + defer { + sender.state = TahoeFeatureFlag.isLiquidGlassEnabled ? .on : .mixed + } + guard sender.state != .on else { + TahoeFeatureFlag.isLiquidGlassEnabled.toggle() + return + } + let alert = NSAlert() + alert.messageText = "This feature is still under development. Are you sure you want to try it out?" + alert.informativeText = "Please restart the app after changing this setting." + alert.addButton(withTitle: "YES") + alert.addButton(withTitle: "NO") + if alert.runModal() == .alertFirstButtonReturn { + TahoeFeatureFlag.isLiquidGlassEnabled.toggle() + } + } + @IBAction func showAboutWindow(_ sender: Any) { coordinator?.showAboutWindow() } @@ -237,7 +285,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { coordinator?.showVideos() } - @objc func applyFilterState(_ sender: Any?) { + @MainActor @objc func applyFilterState(_ sender: Any?) { guard let state = sender as? WWDCFiltersState else { return } coordinator?.applyFilter(state: state) diff --git a/WWDC/ClipSharingViewController.swift b/WWDC/ClipSharingViewController.swift index 14202a6e..2c4e5a0b 100644 --- a/WWDC/ClipSharingViewController.swift +++ b/WWDC/ClipSharingViewController.swift @@ -46,6 +46,7 @@ final class ClipSharingViewController: NSViewController { AVPlayer(playerItem: AVPlayerItem(asset: asset)) }() + private var progressIndicatorLayoutContainer: NSView! private lazy var progressIndicator: NSProgressIndicator = { let v = NSProgressIndicator() @@ -57,7 +58,7 @@ final class ClipSharingViewController: NSViewController { return v }() - private lazy var exportingBackgroundView: NSVisualEffectView = { + private lazy var exportingBackgroundView: NSView = { let v = NSVisualEffectView(frame: view.bounds) v.autoresizingMask = [.width, .height] @@ -102,7 +103,15 @@ final class ClipSharingViewController: NSViewController { view = NSView() view.wantsLayer = true + var progressIndicator: NSView = self.progressIndicator + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + progressIndicator = NSView.verticalGlassContainer(.clear, background: .init(nsColor: .textBackgroundColor).opacity(0.5), padding: 10, groups: [ + [self.progressIndicator] + ]) + progressIndicator.translatesAutoresizingMaskIntoConstraints = false + } view.addSubview(progressIndicator) + progressIndicatorLayoutContainer = progressIndicator NSLayoutConstraint.activate([ progressIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), @@ -168,7 +177,7 @@ final class ClipSharingViewController: NSViewController { } private func beginTrimming() { - view.addSubview(playerView, positioned: .below, relativeTo: progressIndicator) + view.addSubview(playerView, positioned: .below, relativeTo: progressIndicatorLayoutContainer) guard playerView.canBeginTrimming else { hide() @@ -176,6 +185,7 @@ final class ClipSharingViewController: NSViewController { } progressIndicator.stopAnimation(nil) + progressIndicator.isHidden = true if let suggestedTime = initialBeginTime { configureClipForSuggestedTime(suggestedTime) @@ -229,7 +239,11 @@ final class ClipSharingViewController: NSViewController { return } - showProgressUI() + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + showTahoeProgressUI() + } else { + showProgressUI() + } renderer = ClipRenderer( playerItem: item, @@ -278,11 +292,47 @@ final class ClipSharingViewController: NSViewController { ]) } + @available(macOS 26.0, *) + private func showTahoeProgressUI() { + playerView.controlsStyle = .none + + progressIndicator.isIndeterminate = false + progressIndicator.minValue = 0 + progressIndicator.maxValue = 1 + progressIndicator.startAnimation(nil) + + exportingBackgroundView = NSView(frame: view.bounds) + exportingBackgroundView.wantsLayer = true + exportingBackgroundView.layer?.backgroundColor = CGColor(red: 0, green: 0, blue: 0, alpha: 0.3) + let indicator = progressIndicatorLayoutContainer! + view.addSubview(exportingBackgroundView, positioned: .below, relativeTo: indicator) + cancelButton.controlSize = .large + cancelButton.toolTip = "Cancel" + cancelButton.isBordered = false + let cancelContainer = NSView.verticalGlassContainer(.clear, background: .init(nsColor: .textBackgroundColor).opacity(0.5), padding: 10, groups: [ + [cancelButton] + ]) + cancelContainer.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(cancelContainer) + + NSLayoutConstraint.activate([ + cancelContainer.topAnchor.constraint(equalTo: indicator.bottomAnchor, constant: 6), + cancelContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor) + ]) + cancelButton.isHidden = false + progressIndicator.isHidden = false + } + private func updateProgress(with progress: Float) { + progressIndicator.isHidden = false progressIndicator.doubleValue = Double(progress) if progress >= 0.97 { cancelButton.isEnabled = false + if TahoeFeatureFlag.isLiquidGlassEnabled { + progressIndicator.doubleValue = 1 + cancelButton.isHidden = true + } exportingLabel.stringValue = "Done!" progressIndicator.stopAnimation(nil) } diff --git a/WWDC/Constants.swift b/WWDC/Constants.swift index f152b594..c532e94a 100644 --- a/WWDC/Constants.swift +++ b/WWDC/Constants.swift @@ -55,3 +55,8 @@ struct Constants { static let exploreTabSpecialEventLiveSoonInterval: TimeInterval = 12 * 60 * 60 } + +extension Constants { + static let sidebarWidth: CGFloat = 300 + static let minimumWindowHeight: CGFloat = 700 +} diff --git a/WWDC/ContentFilterOption.swift b/WWDC/ContentFilterOption.swift new file mode 100644 index 00000000..ee28b006 --- /dev/null +++ b/WWDC/ContentFilterOption.swift @@ -0,0 +1,29 @@ +// +// ContentFilterOption.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Foundation + +struct ContentFilterOption: PickAnyPickerItem, ExpressibleByStringInterpolation, ExpressibleByStringLiteral { + internal init(label: String? = nil, isSelected: Bool = false) { + self.label = label + self.isSelected = isSelected + } + + static var divider = ContentFilterOption() + let id = UUID() + var label: String? + var isSelected = false + + init(stringLiteral value: String) { + self.init(label: value) + } + + init(stringInterpolation: DefaultStringInterpolation) { + self.init(label: "\(stringInterpolation)") + } +} diff --git a/WWDC/ImageDownloadCenter.swift b/WWDC/ImageDownloadCenter.swift index 8d844d12..9cc434c3 100644 --- a/WWDC/ImageDownloadCenter.swift +++ b/WWDC/ImageDownloadCenter.swift @@ -33,10 +33,18 @@ final class ImageDownloadCenter: Logging { }() func cachedThumbnail(from url: URL) -> NSImage? { cache.cachedImage(for: url, thumbnailOnly: true).thumbnail } + func cachedImage(from url: URL, thumbnailOnly: Bool) -> NSImage? { + let result = cache.cachedImage(for: url, thumbnailOnly: thumbnailOnly) + if thumbnailOnly { + return result.thumbnail + } else { + return result.original + } + } /// The completion handler is always called on the main thread @discardableResult - func downloadImage(from url: URL, thumbnailHeight: CGFloat, thumbnailOnly: Bool = false, completion: @escaping ImageDownloadCompletionBlock) -> Operation? { + func downloadImage(from url: URL, thumbnailHeight: CGFloat?, thumbnailOnly: Bool = false, completion: @escaping ImageDownloadCompletionBlock) -> Operation? { if thumbnailOnly { if let thumbnailImage = cache.cachedImage(for: url, thumbnailOnly: true).thumbnail { completion(url, (nil, thumbnailImage)) @@ -244,11 +252,11 @@ private final class ImageDownloadOperation: Operation, @unchecked Sendable { } let url: URL - let thumbnailHeight: CGFloat + let thumbnailHeight: CGFloat? let cacheProvider: ImageCacheProvider - init(url: URL, cache: ImageCacheProvider, thumbnailHeight: CGFloat = Constants.thumbnailHeight) { + init(url: URL, cache: ImageCacheProvider, thumbnailHeight: CGFloat?) { self.url = url cacheProvider = cache self.thumbnailHeight = thumbnailHeight @@ -312,6 +320,7 @@ private final class ImageDownloadOperation: Operation, @unchecked Sendable { guard let self = self else { return } guard !self.isCancelled else { + self.callCompletionHandlers() // make sure checked continuation is always called self._executing = false self._finished = true return @@ -332,6 +341,7 @@ private final class ImageDownloadOperation: Operation, @unchecked Sendable { } guard !self.isCancelled else { + self.callCompletionHandlers() // make sure checked continuation is always called self._executing = false self._finished = true return diff --git a/WWDC/MainWindowController.swift b/WWDC/MainWindowController.swift index 38527bc9..3e2cf147 100644 --- a/WWDC/MainWindowController.swift +++ b/WWDC/MainWindowController.swift @@ -9,7 +9,7 @@ import Cocoa import PlayerUI -enum MainWindowTab: Int, WWDCTab { +enum MainWindowTab: Int, CaseIterable, Sendable, WWDCTab { case explore case schedule case videos @@ -23,6 +23,17 @@ enum MainWindowTab: Int, WWDCTab { } var hidesWindowTitleBar: Bool { self == .explore } + + var toolbarItemImage: NSImage? { + switch self { + case .explore: + return NSImage(systemSymbolName: "star.square.fill", accessibilityDescription: "Explore") + case .schedule: + return NSImage(systemSymbolName: "calendar", accessibilityDescription: "Schedule") + case .videos: + return NSImage(systemSymbolName: "play.square.fill", accessibilityDescription: "Videos") + } + } } extension Notification.Name { @@ -41,7 +52,6 @@ final class MainWindowController: WWDCWindowController { return NSScreen.main?.visibleFrame.insetBy(dx: 50, dy: 120) ?? NSRect(x: 0, y: 0, width: 1200, height: 600) } - public var sidebarInitWidth: CGFloat? override func loadWindow() { let mask: NSWindow.StyleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] diff --git a/WWDC/MultipleChoiceFilter.swift b/WWDC/MultipleChoiceFilter.swift index afd7c07a..20fa58ea 100644 --- a/WWDC/MultipleChoiceFilter.swift +++ b/WWDC/MultipleChoiceFilter.swift @@ -56,7 +56,7 @@ extension Array where Element == FilterOption { } -struct MultipleChoiceFilter: FilterType { +struct MultipleChoiceFilter: FilterType, Equatable { private static func optionsWithClear(_ newValue: [FilterOption]) -> [FilterOption] { if !newValue.contains(.clear) { var withClear = newValue diff --git a/WWDC/RelatedSessionsViewController.swift b/WWDC/RelatedSessionsViewController.swift index 79c891c9..2724f2b3 100644 --- a/WWDC/RelatedSessionsViewController.swift +++ b/WWDC/RelatedSessionsViewController.swift @@ -9,6 +9,7 @@ import SwiftUI import Combine +@MainActor protocol RelatedSessionsDelegate: AnyObject { func relatedSessions(_ controller: RelatedSessionsViewModel, didSelectSession viewModel: SessionViewModel) } @@ -25,6 +26,7 @@ final class RelatedSessionsViewModel: ObservableObject { weak var delegate: RelatedSessionsDelegate? + @MainActor func selectSession(_ viewModel: SessionViewModel) { delegate?.relatedSessions(self, didSelectSession: viewModel) } diff --git a/WWDC/Search/CapsuleButtonStyle.swift b/WWDC/Search/CapsuleButtonStyle.swift new file mode 100644 index 00000000..bb14e2b4 --- /dev/null +++ b/WWDC/Search/CapsuleButtonStyle.swift @@ -0,0 +1,57 @@ +// +// CapsuleButtonStyle.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +extension ButtonStyle where Self == CapsuleButtonStyle { + static func capsuleButton(highlighted: Bool, highlightedColor: Color? = nil, trailingIcon: Image? = nil, hoveringAlpha: CGFloat = 0.3, horizontalPadding: CGFloat = 10) -> CapsuleButtonStyle { + CapsuleButtonStyle(highlighted: highlighted, highlightedColor: highlightedColor, trailingIcon: trailingIcon, hoveringAlpha: hoveringAlpha, horizontalPadding: horizontalPadding) + } +} + +@available(macOS 26.0, *) +struct CapsuleButtonStyle: ButtonStyle { + var highlighted: Bool + var highlightedColor: Color? + var trailingIcon: Image? + var hoveringAlpha: CGFloat = 0.3 + var horizontalPadding: CGFloat = 10 + @State private var isHovered = false + func makeBody(configuration: Configuration) -> some View { + ZStack { + backgroundColor(isPressed: configuration.isPressed) + .contentShape(.capsule) // expand hit test rect for menus + .clipShape(.capsule) + + HStack { + configuration.label + .multilineTextAlignment(.leading) + .lineLimit(1) + if let trailingIcon { + Spacer() + trailingIcon + } + } + .foregroundStyle(highlighted ? .white : .primary) + .padding(.horizontal, horizontalPadding) + } + .animation(.bouncy, value: configuration.isPressed) + .onHover { isHovering in + withAnimation { + isHovered = isHovering + } + } + } + + @ViewBuilder + private func backgroundColor(isPressed: Bool) -> some View { + (highlighted ? (highlightedColor ?? Color.accentColor) : Color.secondary.opacity(0.3)) + .opacity((isHovered || isPressed) ? hoveringAlpha : 1) + } +} diff --git a/WWDC/Search/CapsuleToggleStyle.swift b/WWDC/Search/CapsuleToggleStyle.swift new file mode 100644 index 00000000..5255b49d --- /dev/null +++ b/WWDC/Search/CapsuleToggleStyle.swift @@ -0,0 +1,32 @@ +// +// CapsuleToggleStyle.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +extension ToggleStyle where Self == CapsuleToggleStyle { + static func capsuleToggle(trailingIcon: Image? = nil, hoveringAlpha: CGFloat = 0.3, horizontalPadding: CGFloat = 10) -> CapsuleToggleStyle { + CapsuleToggleStyle(trailingIcon: trailingIcon, hoveringAlpha: hoveringAlpha, horizontalPadding: horizontalPadding) + } +} + +@available(macOS 26.0, *) +struct CapsuleToggleStyle: ToggleStyle { + var trailingIcon: Image? + var hoveringAlpha: CGFloat = 0.3 + var horizontalPadding: CGFloat = 10 + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + configuration.label + } + .buttonStyle(CapsuleButtonStyle(highlighted: configuration.isOn, trailingIcon: trailingIcon, hoveringAlpha: hoveringAlpha, horizontalPadding: horizontalPadding)) + .animation(.bouncy, value: configuration.isOn) + } +} diff --git a/WWDC/Search/FilterResetButton.swift b/WWDC/Search/FilterResetButton.swift new file mode 100644 index 00000000..bb0cf790 --- /dev/null +++ b/WWDC/Search/FilterResetButton.swift @@ -0,0 +1,53 @@ +// +// FilterResetButton.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +struct FilterResetButton: View { + let count: Int + let action: () -> Void + @State private var showClearIcon = false + @State private var contentWidth: CGFloat? + var body: some View { + Button { + action() + } label: { + HStack(spacing: 3) { + Image(systemName: showClearIcon ? "xmark" : "line.3.horizontal.decrease") + .contentTransition(.symbolEffect(.replace.magic(fallback: .offUp.wholeSymbol), options: .nonRepeating)) + .frame(width: 20, height: 20) + Text("\(count)") + .fontDesign(.monospaced) + .padding(.horizontal, 4) + .capsuleBackground(Color.secondary) + } + .foregroundStyle(.white) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { newValue in + contentWidth = newValue + } + } + .help("Clear") + .frame(width: contentWidth.flatMap { $0 + 15 } ?? 70) + .buttonStyle(.capsuleButton(highlighted: true, highlightedColor: filterTint, hoveringAlpha: 1, horizontalPadding: 0)) + .onHover { isHovering in + withAnimation { + showClearIcon = isHovering + } + } + } + + var filterTint: Color? { + guard count > 0 else { + return nil + } + return showClearIcon ? .red : .accentColor + } +} diff --git a/WWDC/Search/ListContentFilterAccessoryView.swift b/WWDC/Search/ListContentFilterAccessoryView.swift new file mode 100644 index 00000000..074a2ab4 --- /dev/null +++ b/WWDC/Search/ListContentFilterAccessoryView.swift @@ -0,0 +1,158 @@ +// +// ListContentFilterAccessoryView.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// +import SwiftUI + +@available(macOS 26.0, *) +struct ListContentFilterAccessoryView: View { + @State private var controlSize: CGSize? = CGSize(width: 0, height: 30) + @Environment(GlobalSearchCoordinator.self) var coordinator + + @State private var selectedEventOptions = [ContentFilterOption]() + @State private var selectedFocusOptions = [ContentFilterOption]() + @State private var selectedTrackOptions = [ContentFilterOption]() + @State private var selectedToggleOptions = [OptionalToggleFilter]() + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + if let filter = coordinator.eventFilter { + PickAnyMenuPicker(title: filter.emptyTitle, options: filter.pickerOptions, selectedItems: $selectedEventOptions) + .frame(height: controlSize?.height) + } + if let filter = coordinator.focusFilter { + PickAnyMenuPicker(title: filter.emptyTitle, options: filter.pickerOptions, selectedItems: $selectedFocusOptions) + .frame(height: controlSize?.height) + } + if let filter = coordinator.trackFilter { + PickAnyMenuPicker(title: filter.emptyTitle, options: filter.pickerOptions, selectedItems: $selectedTrackOptions) + .frame(height: controlSize?.height) + } + + if let toggleFilters = coordinator.bottomFilters { + Divider() + .frame(height: 10) + PickAnyMenuPicker(title: "All Results", options: toggleFilters, selectedItems: $selectedToggleOptions) + .frame(height: controlSize?.height) + } + } + .padding([.horizontal, .bottom]) + .onChange(of: selectedEventOptions) { oldValue, newValue in + guard newValue != oldValue else { return } + updateEffectiveFilters() + } + .onChange(of: selectedFocusOptions) { oldValue, newValue in + guard newValue != oldValue else { return } + updateEffectiveFilters() + } + .onChange(of: selectedTrackOptions) { oldValue, newValue in + guard newValue != oldValue else { return } + updateEffectiveFilters() + } + .onChange(of: selectedToggleOptions) { oldValue, newValue in + guard newValue != oldValue else { return } + updateEffectiveFilters() + } + .onReceive(coordinator.resetAction.receive(on: DispatchQueue.main)) { _ in + withAnimation { + withTransaction(\.changeReason, .reset) { + selectedEventOptions.removeAll() + selectedFocusOptions.removeAll() + selectedTrackOptions.removeAll() + selectedToggleOptions.removeAll() + } + } + } + } + + private func updateEffectiveFilters() { + var currentFilters = coordinator.effectiveFilters + for idx in currentFilters.indices { + if currentFilters[idx].identifier == .event, var eventFilter = currentFilters[idx] as? MultipleChoiceFilter { + eventFilter.selectedOptions = eventFilter.options.filter({ selectedEventOptions.compactMap(\.label).contains($0.title) }) + currentFilters[idx] = eventFilter + } + if currentFilters[idx].identifier == .focus, var eventFilter = currentFilters[idx] as? MultipleChoiceFilter { + eventFilter.selectedOptions = eventFilter.options.filter({ selectedFocusOptions.compactMap(\.label).contains($0.title) }) + currentFilters[idx] = eventFilter + } + if currentFilters[idx].identifier == .track, var eventFilter = currentFilters[idx] as? MultipleChoiceFilter { + eventFilter.selectedOptions = eventFilter.options.filter({ selectedTrackOptions.compactMap(\.label).contains($0.title) }) + currentFilters[idx] = eventFilter + } + if [.isFavorite, .isDownloaded, .isUnwatched, .hasBookmarks].contains(currentFilters[idx].identifier), var eventFilter = currentFilters[idx] as? OptionalToggleFilter { + eventFilter.isOn = selectedToggleOptions.first(where: { $0.identifier == eventFilter.identifier })?.isOn + currentFilters[idx] = eventFilter + } + } + + coordinator.effectiveFilters = currentFilters + coordinator.updatePredicate(.userInput) + } +} + +private extension GlobalSearchCoordinator { + var eventFilter: MultipleChoiceFilter? { + effectiveFilters.first(where: { $0.identifier == .event }) as? MultipleChoiceFilter + } + + var focusFilter: MultipleChoiceFilter? { + effectiveFilters.first(where: { $0.identifier == .focus }) as? MultipleChoiceFilter + } + + var trackFilter: MultipleChoiceFilter? { + effectiveFilters.first(where: { $0.identifier == .track }) as? MultipleChoiceFilter + } + + var bottomFilters: [OptionalToggleFilter]? { + let filters = effectiveFilters.filter { + [.isFavorite, .isDownloaded, .isUnwatched, .hasBookmarks].contains($0.identifier) + }.compactMap { + $0 as? OptionalToggleFilter + } + return filters.isEmpty ? nil : filters + } +} + +extension OptionalToggleFilter: PickAnyPickerItem { + static func == (lhs: OptionalToggleFilter, rhs: OptionalToggleFilter) -> Bool { + lhs.identifier == rhs.identifier && lhs.isOn == rhs.isOn + } + + var label: String? { + switch identifier { + case .isFavorite: return "Favorites" + case .isDownloaded: return "Downloaded" + case .isUnwatched: return "Unwatched" + case .hasBookmarks: return "Bookmarks" + default: + return nil + } + } + + var isSelected: Bool { + get { isOn == true } + set { + isOn = newValue + } + } + + var id: FilterIdentifier { + identifier + } +} + +private extension MultipleChoiceFilter { + var pickerOptions: [ContentFilterOption] { + options.filter { !$0.isClear }.map { + if $0.isSeparator { + return .divider + } else { + return "\($0.title)" + } + } + } +} diff --git a/WWDC/Search/PickAny.swift b/WWDC/Search/PickAny.swift new file mode 100644 index 00000000..83779542 --- /dev/null +++ b/WWDC/Search/PickAny.swift @@ -0,0 +1,142 @@ +// +// PickAny.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +protocol PickAnyPickerItem: Identifiable, Equatable { + associatedtype Label: StringProtocol + var label: Label? { get } + var isSelected: Bool { get set } +} + +enum PickAnyPickerChangeReason { + case selection + case reset +} + +extension Transaction { + @Entry var changeReason: PickAnyPickerChangeReason? +} + +@available(macOS 26.0, *) +struct PickAnyPicker: View { + private let showClearButton: Bool + private let options: [Item] + @Binding var selectedItems: [Item] + @State private var segmentSize: CGSize? + @Binding private var controlSize: CGSize? + init(showClearButton: Bool = true, options: [Item], selectedItems: Binding<[Item]>, controlSize: Binding? = nil) { + self.showClearButton = showClearButton + self.options = options + _selectedItems = selectedItems + _controlSize = controlSize ?? .constant(nil) + } + + var body: some View { +// ScrollView(.horizontal) { + HStack(spacing: 5) { + if showClearButton && !selectedItems.isEmpty { + FilterResetButton(count: selectedItems.count) { + withAnimation { + withTransaction(\.changeReason, .reset) { + selectedItems.removeAll() + } + } + }.frame(height: segmentSize?.height) + } + SelectAnySegmentControl(options: options, selectedItems: $selectedItems) + .clipShape(RoundedRectangle(cornerRadius: segmentSize.flatMap { $0.height * 0.5 } ?? 0)) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newValue in + segmentSize = newValue + controlSize = newValue + } + } +// .padding(.bottom, 10) +// } +// .scrollDisabled(selectedItems.isEmpty) + } +} + +@available(macOS 26.0, *) +struct PickAnyMenuPicker: View { + let title: String + let showClearButton: Bool + @State var options: [Item] + @Binding var selectedItems: [Item] + let ignoreUpdates = State(initialValue: false) // no need to update ui + init(title: String, options: [Item], showClearButton: Bool = true, selectedItems: Binding<[Item]>) { + self.title = title + self.showClearButton = showClearButton + self.options = options + _selectedItems = selectedItems + } + + var body: some View { + #if DEBUG + // swiftlint:disable:next redundant_discardable_let + let _ = Self._printChanges() + #endif + HStack(spacing: 5) { + if showClearButton && !selectedItems.isEmpty { + FilterResetButton(count: selectedItems.count) { + withAnimation { + selectedItems.removeAll() + } + } + } + Menu { + ForEach($options) { option in + if let label = option.wrappedValue.label { + Toggle(label, isOn: option.isSelected) + } else { + Divider() + } + } + } label: { + titleView + .contentShape(Rectangle()) + } + .buttonStyle(.capsuleButton(highlighted: !selectedItems.isEmpty, trailingIcon: Image(systemName: "chevron.up.chevron.down"))) + } + .frame(maxWidth: .infinity) + .onChange(of: options) { oldValue, newValue in + guard newValue != oldValue, !ignoreUpdates.wrappedValue else { + return + } + withAnimation { + selectedItems = newValue.filter { $0.isSelected == true }.compactMap { $0 } + } + } + .onChange(of: selectedItems) { oldValue, newValue in + guard newValue != oldValue else { + return + } + ignoreUpdates.wrappedValue = true + for idx in options.indices { + options[idx].isSelected = newValue.contains(options[idx]) + } + ignoreUpdates.wrappedValue = false + } + } + + private var titleView: some View { + Group { + if selectedItems.isEmpty { + Text(title) + .transition(.blurReplace) + } else { + Text(selectedItems.compactMap(\.label).joined(separator: ", ")) + .transition(.blurReplace) + } + } + .foregroundStyle(.primary) + .animation(.default, value: selectedItems) + } +} diff --git a/WWDC/Search/SelectAnySegmentControl.swift b/WWDC/Search/SelectAnySegmentControl.swift new file mode 100644 index 00000000..191ad0a0 --- /dev/null +++ b/WWDC/Search/SelectAnySegmentControl.swift @@ -0,0 +1,79 @@ +// +// SelectAnySegmentControl.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import SwiftUI + +@available(macOS 26.0, *) +struct SelectAnySegmentControl: NSViewRepresentable { + let options: [Item] + @Binding var selectedItems: [Item] + init(options: [Item], selectedItems: Binding<[Item]>) { + self.options = options + _selectedItems = selectedItems + } + + private func onSelectionChange(_ control: NSSegmentedControl) { + guard control.segmentCount == options.count else { + return + } + + withTransaction(\.changeReason, .selection) { + withAnimation { + selectedItems = options.indices.map { idx in + var item = options[idx] + item.isSelected = control.isSelected(forSegment: idx) + return item + }.filter { $0.isSelected } + } + } + } + + func makeNSView(context: Context) -> NSSegmentedControl { + let control = NSSegmentedControl(labels: options.compactMap(\.label).map(String.init(_:)), trackingMode: .selectAny, target: context.coordinator, action: #selector(Coordinator.selectionDidChange)) + control.segmentStyle = .roundRect + context.coordinator.onSelectionChange = onSelectionChange(_:) + control.borderShape = .capsule + control.segmentDistribution = .fillProportionally + return control + } + + func updateNSView(_ nsView: NSSegmentedControl, context: Context) { + guard context.transaction.changeReason == .reset else { + return + } + + for idx in 0 ..< nsView.segmentCount { + nsView.setSelected(false, forSegment: idx) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var shouldReceiveUpdates = true + var onSelectionChange: ((_ control: NSSegmentedControl) -> Void)? + @objc func selectionDidChange(_ control: NSSegmentedControl) { + onSelectionChange?(control) + } + } +} + +#Preview { + @Previewable @State var selectedItems: [ContentFilterOption] = [] + if #available(macOS 26.0, *) { + PickAnyPicker(options: [ + "Favorites", + "Downloaded", + "UnWatched", + "Bookmarks" + ], selectedItems: $selectedItems) + } +} diff --git a/WWDC/Search/View+CapsuleBackground.swift b/WWDC/Search/View+CapsuleBackground.swift new file mode 100644 index 00000000..2b530560 --- /dev/null +++ b/WWDC/Search/View+CapsuleBackground.swift @@ -0,0 +1,22 @@ +// +// View+CapsuleBackground.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder + func capsuleBackground(_ style: Style) -> some View { + background { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: geometry.size.height * 0.5) + .fill(style) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .center) + } + } + } +} diff --git a/WWDC/SearchCoordinator.swift b/WWDC/SearchCoordinator.swift index 0e124947..9a9a4ab5 100644 --- a/WWDC/SearchCoordinator.swift +++ b/WWDC/SearchCoordinator.swift @@ -21,7 +21,7 @@ final class SearchCoordinator: Logging { /// The desired state of the filters upon configuration private var restorationFiltersState: WWDCFiltersState? - fileprivate let scheduleSearchController: SearchFiltersViewController + let scheduleSearchController: SearchFiltersViewController @Published var scheduleFilterPredicate: FilterPredicate = .init(predicate: nil, changeReason: .initialValue) { willSet { log.debug( @@ -30,7 +30,7 @@ final class SearchCoordinator: Logging { } } - fileprivate let videosSearchController: SearchFiltersViewController + let videosSearchController: SearchFiltersViewController @Published var videosFilterPredicate: FilterPredicate = .init(predicate: nil, changeReason: .initialValue) { willSet { log.debug("Videos new predicate: \(newValue.predicate?.description ?? "nil", privacy: .public)") diff --git a/WWDC/SearchFiltersViewController.swift b/WWDC/SearchFiltersViewController.swift index d89b7d16..7c3b9b1d 100644 --- a/WWDC/SearchFiltersViewController.swift +++ b/WWDC/SearchFiltersViewController.swift @@ -60,6 +60,7 @@ final class SearchFiltersViewController: NSViewController { // swiftlint:disable:next force_cast return storyboard.instantiateController(withIdentifier: "SearchFiltersViewController") as! SearchFiltersViewController } + var showFilterButton = true @IBOutlet var filterContainer: NSView! @IBOutlet weak var eventsPopUp: NSPopUpButton! @@ -170,9 +171,11 @@ final class SearchFiltersViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() - + filterButton.isHidden = !showFilterButton /// Move background and content from behind the title bar. vfxView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true + vfxView.blendingMode = .behindWindow + vfxView.material = .menu setFilters(hidden: true) @@ -180,6 +183,9 @@ final class SearchFiltersViewController: NSViewController { } func setFilters(hidden: Bool) { + guard !filterButton.isHidden else { + return + } filterButton.state = NSControl.StateValue(rawValue: hidden ? 0 : 1) filterContainer.isHidden = hidden } diff --git a/WWDC/SessionActionsView.swift b/WWDC/SessionActionsView.swift index a1a2ac18..6043e94e 100644 --- a/WWDC/SessionActionsView.swift +++ b/WWDC/SessionActionsView.swift @@ -11,7 +11,8 @@ import SwiftUI struct SessionActionsView: View { @ObservedObject var viewModel: SessionActionsViewModel - + var alignment = Alignment.leading + var body: some View { HStack(spacing: 0) { PUIButtonView(.alwaysHighlighted(image: .slides)) { @@ -59,7 +60,7 @@ struct SessionActionsView: View { .opacity(viewModel.calendarButtonIsHidden ? 0 : 1) .frame(width: viewModel.calendarButtonIsHidden ? 0 : nil, alignment: .trailing) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } /// States managed by DownloadState enum: diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index dbe066b0..fe44247a 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -23,11 +23,12 @@ protocol SessionActionsDelegate: AnyObject { func sessionActionsDidSelectShareClip(_ sender: NSView?) } -@MainActor final class SessionActionsViewModel: ObservableObject { @Published var viewModel: SessionViewModel? { didSet { - updateBindings() + DispatchQueue.main.async { + self.updateBindings() + } } } @@ -42,8 +43,9 @@ final class SessionActionsViewModel: ObservableObject { init(session: SessionViewModel? = nil) { self.viewModel = session - - updateBindings() + DispatchQueue.main.async { + self.updateBindings() + } } enum DownloadState: Equatable { @@ -68,6 +70,13 @@ final class SessionActionsViewModel: ObservableObject { } } + var showsInlineButton: Bool { + switch self { + case .downloadable, .downloaded, .pending, .downloading: true + case .notDownloadable: false + } + } + var allocatesSpace: Bool { switch self { case .downloadable, .pending, .downloading, .downloaded: true @@ -80,7 +89,7 @@ final class SessionActionsViewModel: ObservableObject { } } - private func updateBindings() { + @MainActor private func updateBindings() { cancellables = [] guard let viewModel = viewModel else { return } @@ -133,7 +142,7 @@ final class SessionActionsViewModel: ObservableObject { /// Realm writes and then publishes the new `isDownloaded` state. /// /// This means the button will show the download button briefly before switching to the delete button. - private static func downloadState(session: Session, downloadState: MediaDownloadState?) -> DownloadState { + static func downloadState(session: Session, downloadState: MediaDownloadState?) -> DownloadState { if let downloadState { switch downloadState { case .waiting: @@ -165,35 +174,35 @@ final class SessionActionsViewModel: ObservableObject { return .downloadable } - func toggleFavorite() { + @MainActor func toggleFavorite() { delegate?.sessionActionsDidSelectFavorite(nil) } - func showSlides() { + @MainActor func showSlides() { delegate?.sessionActionsDidSelectSlides(nil) } - func download() { + @MainActor func download() { delegate?.sessionActionsDidSelectDownload(nil) } - func addCalendar() { + @MainActor func addCalendar() { delegate?.sessionActionsDidSelectCalendar(nil) } - func deleteDownload() { + @MainActor func deleteDownload() { delegate?.sessionActionsDidSelectDeleteDownload(nil) } - func share() { + @MainActor func share() { delegate?.sessionActionsDidSelectShare(nil) } - func shareClip() { + @MainActor func shareClip() { delegate?.sessionActionsDidSelectShareClip(nil) } - func cancelDownload() { + @MainActor func cancelDownload() { delegate?.sessionActionsDidSelectCancelDownload(nil) } } diff --git a/WWDC/SessionDetailsView.swift b/WWDC/SessionDetailsView.swift index 9979801a..b46d0edc 100644 --- a/WWDC/SessionDetailsView.swift +++ b/WWDC/SessionDetailsView.swift @@ -35,6 +35,18 @@ import SwiftUI */ struct SessionDetailsView: View { @ObservedObject var detailsViewModel: SessionDetailsViewModel + + var body: some View { + if TahoeFeatureFlag.isLiquidGlassEnabled { + NewSessionDetailsView(detailsViewModel: detailsViewModel) + } else { + DeprecatedSessionDetailsView(detailsViewModel: detailsViewModel) + } + } +} + +struct DeprecatedSessionDetailsView: View { + @ObservedObject var detailsViewModel: SessionDetailsViewModel var body: some View { VStack(spacing: 0) { @@ -51,7 +63,7 @@ struct SessionDetailsView: View { tabContent .padding(.top, 16) } - .padding([.bottom, .horizontal], 46) + .padding([.bottom, .horizontal]) } private var tabButtons: some View { @@ -99,3 +111,78 @@ struct SessionDetailsView_Previews: PreviewProvider { SessionDetailsView(detailsViewModel: SessionDetailsViewModel(session: .preview)) } } + +struct NewSessionDetailsView: View { + @ObservedObject var detailsViewModel: SessionDetailsViewModel + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + ShelfViewControllerWrapper(controller: detailsViewModel.shelfController) + .frame(minHeight: 280, maxHeight: .infinity) + + if detailsViewModel.isBookmarksAvailable { + tabButtons + .safeAreaPadding(.leading, geometry.safeAreaInsets.leading) + .safeAreaPadding(.trailing, geometry.safeAreaInsets.trailing) + .padding([.bottom, .horizontal]) + } + + Divider() + .safeAreaPadding(.leading, geometry.safeAreaInsets.leading) + .safeAreaPadding(.trailing, geometry.safeAreaInsets.trailing) + .padding([.bottom, .horizontal]) + + tabContent + .padding(.top, 16) + .safeAreaPadding(.leading, geometry.safeAreaInsets.leading) + .safeAreaPadding(.trailing, geometry.safeAreaInsets.trailing) + .padding([.bottom, .horizontal]) + } + .ignoresSafeArea() + } + } + + private var tabButtons: some View { + HStack(spacing: 32) { + Button("Overview") { + detailsViewModel.selectedTab = .overview + } + .selected(detailsViewModel.selectedTab == .overview) + + if detailsViewModel.isBookmarksAvailable { + Button("Bookmarks") { + detailsViewModel.selectedTab = .bookmarks + } + .selected(detailsViewModel.selectedTab == .bookmarks) + } + } + .buttonStyle(WWDCTextButtonStyle()) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + @ViewBuilder + private var tabContent: some View { + switch detailsViewModel.selectedTab { + case .overview: + SessionSummaryViewControllerWrapper(controller: detailsViewModel.summaryController) + case .transcript: + Text("See inspector for transcript") + .foregroundColor(.secondary) + case .bookmarks: + Text("Bookmarks view coming soon") + .foregroundColor(.secondary) + } + } +} + +extension View { + @ViewBuilder + func extendBackground(isHidden: Bool = false) -> some View { + if #available(macOS 26.0, *), !isHidden { + backgroundExtensionEffect() + } else { + self + } + } +} diff --git a/WWDC/SessionDetailsViewController.swift b/WWDC/SessionDetailsViewController.swift index 4e944d1f..820c6f48 100644 --- a/WWDC/SessionDetailsViewController.swift +++ b/WWDC/SessionDetailsViewController.swift @@ -54,7 +54,7 @@ class SessionDetailsViewModel: ObservableObject { } extension SessionDetailsViewModel { - enum SessionTab { + enum SessionTab: CaseIterable { case overview, transcript, bookmarks } } @@ -65,7 +65,10 @@ final class SessionDetailsViewController: NSViewController { var viewModel: SessionViewModel? { didSet { - view.animator().alphaValue = (viewModel == nil) ? 0 : 1 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.35 + view.animator().alphaValue = (viewModel == nil) ? 0 : 1 + } detailsViewModel.viewModel = viewModel } } diff --git a/WWDC/SessionRow.swift b/WWDC/SessionRow.swift index 6b6946d2..82fb85d2 100644 --- a/WWDC/SessionRow.swift +++ b/WWDC/SessionRow.swift @@ -9,7 +9,7 @@ import Foundation enum SessionRowKind { - case sectionHeader(TopicHeaderRowContent) + case sectionHeader(_ title: String, _ symbolName: String?) case session(SessionViewModel) var isHeader: Bool { @@ -21,15 +21,6 @@ enum SessionRowKind { } } - var headerContent: TopicHeaderRowContent? { - switch self { - case .sectionHeader(let content): - return content - default: - return nil - } - } - var isSession: Bool { switch self { case .session: @@ -57,18 +48,17 @@ final class SessionRow: CustomDebugStringConvertible { kind = .session(viewModel) } - init(content: TopicHeaderRowContent) { - kind = .sectionHeader(content) + init(title: String, symbolName: String? = nil) { + kind = .sectionHeader(title, symbolName) } convenience init(date: Date, showTimeZone: Bool = false) { let title = SessionViewModel.standardFormatted(date: date, withTimeZoneName: showTimeZone) - self.init(content: .init(title: title)) + self.init(title: title) } var isHeader: Bool { kind.isHeader } - var headerContent: TopicHeaderRowContent? { kind.headerContent } var isSession: Bool { kind.isSession } var sessionViewModel: SessionViewModel? { kind.sessionViewModel } func represents(session: SessionIdentifiable) -> Bool { @@ -77,8 +67,8 @@ final class SessionRow: CustomDebugStringConvertible { var debugDescription: String { switch kind { - case .sectionHeader(let content): - return "Header: " + content.title + case .sectionHeader(let title, _): + return "Header: " + title case .session(let viewModel): return "Session: " + viewModel.identifier + " " + viewModel.title } @@ -90,8 +80,9 @@ extension SessionRow: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(String(reflecting: kind)) switch kind { - case let .sectionHeader(title): + case let .sectionHeader(title, symbol): hasher.combine(title) + hasher.combine(symbol) case let .session(viewModel): hasher.combine(viewModel.identifier) hasher.combine(viewModel.trackName) @@ -102,7 +93,7 @@ extension SessionRow: Hashable { /// each view is bound to a realm object so there is no need to create a new row static func == (lhs: SessionRow, rhs: SessionRow) -> Bool { switch (lhs.kind, rhs.kind) { - case let (.sectionHeader(lhsTitle), .sectionHeader(rhsTitle)) where lhsTitle == rhsTitle: + case let (.sectionHeader(lhsTitle, lhsSymbol), .sectionHeader(rhsTitle, rhsSymbol)) where lhsTitle == rhsTitle && lhsSymbol == rhsSymbol: return true case let (.session(lhsViewModel), .session(rhsViewModel)) where lhsViewModel.identifier == rhsViewModel.identifier && lhsViewModel.trackName == rhsViewModel.trackName: diff --git a/WWDC/SessionRowProvider.swift b/WWDC/SessionRowProvider.swift index 405ab04b..a6914dad 100644 --- a/WWDC/SessionRowProvider.swift +++ b/WWDC/SessionRowProvider.swift @@ -134,7 +134,7 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { uniqueKeysWithValues: trackToSortedSessions.compactMap { (track, trackSessions) -> (SessionRow, (Results, OrderedDictionary))? in guard !trackSessions.isEmpty else { return nil } - let titleRow = SessionRow(content: .init(title: track.name, symbolName: track.symbolName)) + let titleRow = SessionRow(title: track.name, symbolName: track.symbolName) let sessionRows = trackSessions.compactMap { session -> (String, SessionRow)? in guard let viewModel = SessionViewModel(session: session, track: track) else { return nil } diff --git a/WWDC/SessionViewModel.swift b/WWDC/SessionViewModel.swift index f50291a5..f415543e 100644 --- a/WWDC/SessionViewModel.swift +++ b/WWDC/SessionViewModel.swift @@ -375,7 +375,7 @@ extension SessionViewModel { /// /// I think we can build a preview helper that does a Boot().bootstrapDependencies(then:), but it's async /// so it's a bit of effort. For now, just brute force to get a session. - static var preview: SessionViewModel { + @MainActor static var preview: SessionViewModel { let delegate = (NSApplication.shared.delegate as! AppDelegate) // swiftlint:disable:this force_cast Thread.sleep(forTimeInterval: 0.5) // TODO: Get access to storage in a better way let coordinator = delegate.coordinator! diff --git a/WWDC/SessionsSplitViewController.swift b/WWDC/SessionsSplitViewController.swift index b0dff73c..46281391 100644 --- a/WWDC/SessionsSplitViewController.swift +++ b/WWDC/SessionsSplitViewController.swift @@ -19,24 +19,16 @@ final class SessionsSplitViewController: NSSplitViewController { let listViewController: SessionsTableViewController let detailViewController: SessionDetailsViewController var isResizingSplitView = false - let windowController: MainWindowController + let windowController: WWDCWindowControllerObject var setupDone = false private var cancellables: Set = [] - init(windowController: MainWindowController, listViewController: SessionsTableViewController) { + init(windowController: WWDCWindowControllerObject, listViewController: SessionsTableViewController) { self.windowController = windowController self.listViewController = listViewController let detailViewController = SessionDetailsViewController() self.detailViewController = detailViewController - listViewController.$selectedSession.receive(on: DispatchQueue.main).sink { viewModel in - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.35 - - detailViewController.viewModel = viewModel - } - }.store(in: &cancellables) - super.init(nibName: nil, bundle: nil) NotificationCenter diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index 881e7f7a..7f68bf10 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -583,11 +583,10 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { switch displayedRows[row].kind { - case .sectionHeader(let content): + case .sectionHeader(let title, let symbolName): let rowView: TopicHeaderRow? = rowView(with: .headerRow) - - rowView?.content = content - + rowView?.title = title + rowView?.symbolName = symbolName return rowView default: return rowView(with: .sessionRow) diff --git a/WWDC/ShelfViewController.swift b/WWDC/ShelfViewController.swift index baa917d3..a4fabe0e 100644 --- a/WWDC/ShelfViewController.swift +++ b/WWDC/ShelfViewController.swift @@ -13,6 +13,7 @@ import PlayerUI import AVFoundation import SwiftUI +@MainActor protocol ShelfViewControllerDelegate: AnyObject { func shelfViewControllerDidSelectPlay(_ controller: ShelfViewController) func shelfViewController(_ controller: ShelfViewController, didBeginClipSharingWithHost hostView: NSView) @@ -28,7 +29,7 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr var viewModel: SessionViewModel? { didSet { - updateBindings() + updateBindings() // changes along with $selectedSession } } @@ -48,7 +49,18 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr return v }() - lazy var playButton: VibrantButton = { + lazy var playButton: NSView = { + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + let b = NSButton(title: "Play", image: NSImage(systemSymbolName: "play.fill", accessibilityDescription: "Play")!, target: self, action: #selector(play)) + b.isBordered = true + b.bezelStyle = .glass + b.controlSize = .extraLarge + b.tintProminence = .automatic + b.contentTintColor = .clear + b.borderShape = .roundedRectangle + b.translatesAutoresizingMaskIntoConstraints = false + return b + } let b = VibrantButton(frame: .zero) b.title = "Play" @@ -70,8 +82,23 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: MainWindowController.defaultRect.width - 300, height: MainWindowController.defaultRect.height / 2)) - view.wantsLayer = true - + let shelfView: NSView + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + let extensionView = NSBackgroundExtensionView() + extensionView.contentView = self.shelfView + extensionView.automaticallyPlacesContentView = false + extensionView.translatesAutoresizingMaskIntoConstraints = false + shelfView = extensionView + // only enable reflection effect on leading edge + NSLayoutConstraint.activate([ + self.shelfView.topAnchor.constraint(equalTo: extensionView.topAnchor), + self.shelfView.leadingAnchor.constraint(equalTo: extensionView.safeAreaLayoutGuide.leadingAnchor), + self.shelfView.bottomAnchor.constraint(equalTo: extensionView.bottomAnchor), + self.shelfView.trailingAnchor.constraint(equalTo: extensionView.trailingAnchor) + ]) + } else { + shelfView = self.shelfView + } view.addSubview(shelfView) shelfView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true shelfView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true @@ -79,8 +106,8 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr shelfView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true view.addSubview(playButton) - playButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - playButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + playButton.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true + playButton.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor).isActive = true view.addSubview(playerContainer) playerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true @@ -143,6 +170,26 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr self.delegate?.shelfViewControllerDidSelectPlay(self) } + private var playerView: PUIPlayerView? + func addPlayerViewIfNeeded(_ playerController: VideoPlayerViewController) { + + // Already attached + guard playerController.view.superview != playerContainer else { return } + playerView = playerController.playerView + playerController.view.frame = playerContainer.bounds + playerController.view.alphaValue = 0 + playerController.view.isHidden = false + + playerController.view.translatesAutoresizingMaskIntoConstraints = false + + playerContainer.addSubview(playerController.view) + playerContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) + + playerContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) + + playerController.view.alphaValue = 1 + } + private var sharingController: ClipSharingViewController? func showClipUI() { @@ -162,7 +209,8 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr addChild(controller) controller.view.autoresizingMask = [.width, .height] - controller.view.frame = playerContainer.bounds + + controller.view.frame = playerContainer.safeAreaRect(including: .leading) playerContainer.addSubview(controller.view) sharingController = controller @@ -217,7 +265,7 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr self.detachedSessionID = viewModel?.sessionIdentifier self.detachedPlayer = player - installDetachedStatusControllerIfNeeded() + installDetachedStatusControllerIfNeeded(status) detachedStatusController.status = status @@ -238,25 +286,33 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr private lazy var detachedStatusController = PUIDetachedPlaybackStatusViewController() - private func installDetachedStatusControllerIfNeeded() { - guard detachedStatusController.parent == nil else { return } - - updateVideoLayoutGuide() - - addChild(detachedStatusController) - + private func installDetachedStatusControllerIfNeeded(_ status: DetachedPlaybackStatus) { + guard let playerView else { return } // make sure already played once, otherwise no need to insert this status view + if detachedStatusController.parent == nil { + // add child once, move views based on the status + addChild(detachedStatusController) + } let statusView = detachedStatusController.view statusView.wantsLayer = true statusView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(statusView, positioned: .above, relativeTo: view.subviews.first) - - statusView.layer?.zPosition = 9 - + statusView.layer?.zPosition = 9 // only works with siblings, for more info: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/BuildingaLayerHierarchy/BuildingaLayerHierarchy.html?utm_source=chatgpt.com#:~:text=top%20of%20any-,siblings,-with%20the%20same + // for simplicity, just re-add this view to avoid all those buggy checks + statusView.removeFromSuperview() + let parent: NSView + if status.isFullScreen { + // add to playerContainer + parent = playerContainer + } else { + // add between AVPlayer and controls + parent = playerView + } + parent.addSubview(statusView.backgroundExtensionEffect(reflect: .leading, isEnabled: TahoeFeatureFlag.isLiquidGlassEnabled), positioned: .above, relativeTo: parent.subviews.first) + // The status view is placed inside the player view, so layout guide constraints are unnecessary NSLayoutConstraint.activate([ - statusView.leadingAnchor.constraint(equalTo: videoLayoutGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: videoLayoutGuide.trailingAnchor), - statusView.topAnchor.constraint(equalTo: videoLayoutGuide.topAnchor), - statusView.bottomAnchor.constraint(equalTo: videoLayoutGuide.bottomAnchor) + statusView.leadingAnchor.constraint(equalTo: parent.safeAreaLayoutGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: parent.trailingAnchor), + statusView.topAnchor.constraint(equalTo: parent.topAnchor), + statusView.bottomAnchor.constraint(equalTo: parent.bottomAnchor) ]) } @@ -282,3 +338,25 @@ struct ShelfViewControllerWrapper: NSViewControllerRepresentable { // No updates needed - controller manages its own state } } + +private extension NSView { + func safeAreaRect(including edges: Edge.Set = .all) -> CGRect { + let insets = self.safeAreaInsets + var rect = self.bounds + if edges.contains(.bottom) { + rect.origin.y += insets.bottom + rect.size.height -= insets.bottom + } + if edges.contains(.top) { + rect.size.height -= insets.top + } + if edges.contains(.leading) { + rect.origin.x += insets.left + rect.size.width -= insets.left + } + if edges.contains(.trailing) { + rect.size.width -= insets.right + } + return rect + } +} diff --git a/WWDC/Tahoe/Explore/NewExploreCategoryList.swift b/WWDC/Tahoe/Explore/NewExploreCategoryList.swift new file mode 100644 index 00000000..4dd7d3e6 --- /dev/null +++ b/WWDC/Tahoe/Explore/NewExploreCategoryList.swift @@ -0,0 +1,42 @@ +// +// NewExploreCategoryList.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +struct NewExploreCategoryList: View { + @Environment(NewExploreViewModel.self) var viewModel + @State private var sections: [ExploreTabContent.Section] = [] + @FocusState private var isListFocused: Bool + var body: some View { + ScrollViewReader { proxy in + @Bindable var viewModel = viewModel + List(sections, selection: $viewModel.selectedCategory) { section in + Label { + Text(section.title) + } icon: { + section.icon + } + } + .focused($isListFocused) + .onChange(of: viewModel.selectedCategory) { _, newValue in + proxy.scrollTo(newValue, anchor: .bottom) + } + } + .scrollEdgeEffectStyle(.soft, for: .all) + .onAppear { + isListFocused = true + } + .onReceive(viewModel.provider.$content.receive(on: DispatchQueue.main)) { newContent in + sections = newContent?.sections ?? [] + if viewModel.selectedCategory == nil { + viewModel.selectedCategory = sections.first?.id + } + } + } +} diff --git a/WWDC/Tahoe/Explore/NewExploreTabContentView.swift b/WWDC/Tahoe/Explore/NewExploreTabContentView.swift new file mode 100644 index 00000000..fe5fbd53 --- /dev/null +++ b/WWDC/Tahoe/Explore/NewExploreTabContentView.swift @@ -0,0 +1,105 @@ +// +// NewExploreTabContentView.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +@MainActor +struct NewExploreTabContentView: View { + static let cardImageCornerRadius: CGFloat = 8 + static let cardWidth: CGFloat = 240 + static let cardImageHeight: CGFloat = 134 + + var content: ExploreTabContent + + @Environment(NewExploreViewModel.self) var viewModel + + @State private var isPresentingLiveEvent = false + + var body: some View { + scrollView + .overlay { + if let liveItem = content.liveEventItem, isPresentingLiveEvent { + LiveStreamOverlay(item: liveItem) { + isPresentingLiveEvent = false + } + .animation(.default, value: content.isLiveEventStreaming) + } + } + .onAppear { + /// Automatically present live event item when even is currently live + if content.isLiveEventStreaming { + isPresentingLiveEvent = true + } + } + } + + @ViewBuilder + private var scrollView: some View { + @Bindable var viewModel = viewModel + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 42) { + liveHeader + + ForEach(content.sections) { section in + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 6) { + section.icon + + Text(section.title) + } + .padding(.horizontal) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .padding(.leading, 2) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 16) { + ForEach(section.items) { item in + ExploreTabItemView(layout: section.layout, item: item) + .contentShape(Rectangle()) + .onTapGesture { open(item) } + } + } + .padding(.horizontal) + } + } + .id(section.id) // for scroll position to work perfectly + } + } + .padding(.vertical) + .blur(radius: isPresentingLiveEvent ? 24 : 0) + .scrollTargetLayout() + } + .scrollPosition(id: $viewModel.selectedCategory, anchor: .center) + .animation(.smooth, value: viewModel.selectedCategory) + } + + @ViewBuilder + private var liveHeader: some View { + if let liveItem = content.liveEventItem { + ExploreTabItemView(layout: .card, item: liveItem) + .padding(.horizontal) + .onTapGesture { + isPresentingLiveEvent = true + } + } + } + + @MainActor + private func open(_ item: ExploreTabContent.Item) { + guard let destination = item.destination else { + return + } + + switch destination { + case .command(let command): + AppDelegate.run(command) + case .url(let url): + NSWorkspace.shared.open(url) + } + } +} diff --git a/WWDC/Tahoe/Explore/NewExploreTabDetailView.swift b/WWDC/Tahoe/Explore/NewExploreTabDetailView.swift new file mode 100644 index 00000000..306f7ea7 --- /dev/null +++ b/WWDC/Tahoe/Explore/NewExploreTabDetailView.swift @@ -0,0 +1,40 @@ +// +// NewExploreTabDetailView.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +struct NewExploreTabDetailView: View { + @Environment(NewExploreViewModel.self) var viewModel + @State private var content: ExploreTabContent? + + var body: some View { + Group { + if let content = content { + @Bindable var model = viewModel + NewExploreTabContentView(content: content) + .environment(viewModel) + #if DEBUG + .contextMenu { Button("Export JSON…", action: content.exportJSON) } + #endif + .transition(.blurReplace) + } else { + ExploreTabContentView(content: .placeholder, scrollOffset: .constant(.zero)) + .redacted(reason: .placeholder) + .transition(.blurReplace) + } + } + .scrollEdgeEffectStyle(.soft, for: .top) + .onReceive(viewModel.provider.$content.receive(on: DispatchQueue.main)) { newContent in + content = newContent + } + .task { + viewModel.provider.activate() + } + } +} diff --git a/WWDC/Tahoe/Explore/NewExploreViewModel.swift b/WWDC/Tahoe/Explore/NewExploreViewModel.swift new file mode 100644 index 00000000..1eee4d26 --- /dev/null +++ b/WWDC/Tahoe/Explore/NewExploreViewModel.swift @@ -0,0 +1,22 @@ +// +// NewExploreViewModel.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +@Observable +class NewExploreViewModel { + let provider: ExploreTabProvider + + var selectedCategory: String? + var scrollPosition = ScrollPosition(idType: String.self) + + init(provider: ExploreTabProvider) { + self.provider = provider + } +} diff --git a/WWDC/Tahoe/GlobalSearchCoordinator.swift b/WWDC/Tahoe/GlobalSearchCoordinator.swift new file mode 100644 index 00000000..2a8eb502 --- /dev/null +++ b/WWDC/Tahoe/GlobalSearchCoordinator.swift @@ -0,0 +1,307 @@ +// +// GlobalSearchCoordinator.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// +import AppKit +import Combine +import ConfCore +import OSLog +import RealmSwift + +@Observable +class GlobalSearchCoordinator: Logging { + enum SearchTarget: String, CaseIterable { + case sessions = "Sessions" + case transcripts = "Transcripts" + } + + @ObservationIgnored private var cancellables: Set = [] + + @ObservationIgnored static let log = makeLogger() + + /// The desired state of the filters upon configuration + @ObservationIgnored fileprivate var restorationFiltersState: WWDCFiltersState? + + // view action caller, to avoid two way bindings + @ObservationIgnored let resetAction = PassthroughSubject() + + fileprivate var tabState: GlobalSearchTabState { + willSet { + predicate = newValue.filterPredicate + } + } + + var effectiveFilters: [FilterType] { + get { tabState.effectiveFilters } + set { tabState.effectiveFilters = newValue } + } + + var availableSearchTargets = [SearchTarget.sessions] + var searchTarget = SearchTarget.sessions + + @ObservationIgnored @Published var predicate = FilterPredicate(predicate: nil, changeReason: .initialValue) + + init( + _ storage: Storage, + tabState: GlobalSearchTabState, + restorationFiltersState: String? = nil + ) { + self.restorationFiltersState = restorationFiltersState + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONDecoder().decode(WWDCFiltersState.self, from: $0) } + self.tabState = tabState + + NotificationCenter.default.publisher(for: .MainWindowWantsToSelectSearchField) + .sink { [weak self] _ in + DispatchQueue.main.async { + self?.activateSearchField() + } + } + .store(in: &cancellables) + + Publishers.CombineLatest4( + storage.eventsForFiltering, + storage.focuses, + storage.tracks, + storage.allSessionTypes + ) + .replaceErrorWithEmpty() + .sink { [weak self] events, focuses, tracks, sessionTypes in + self?.configureFilters( + events: events.toArray(), + focuses: focuses.toArray(), + tracks: tracks.toArray(), + sessionTypes: sessionTypes + ) + } + .store(in: &cancellables) + } + + func updatePredicate(_ reason: FilterChangeReason) { + guard searchTarget == .sessions else { + // transcript will handle changes on its own + return + } + tabState.updatePredicate(reason) + } + + /// Updates the selected filter options with the ones in the provided state + /// Useful for programmatically changing the selected filters + func apply(for tab: WWDCFiltersState.Tab) { + guard searchTarget == .sessions else { + // transcript will handle changes on its own + return + } + if var filters = IntermediateFiltersStructure.from(existingFilters: tabState.effectiveFilters) { + filters.apply(tab) + tabState.effectiveFilters = filters.all + tabState.updatePredicate(.userInput) + } + } + + fileprivate func configureFilters(events: [Event], focuses: [Focus], tracks: [Track], sessionTypes: [String]) { + restorationFiltersState = nil + // subclass override + } + + @MainActor + private func activateSearchField() { + if + let window = (NSApp.delegate as? AppDelegate)?.coordinator?.windowController.window, + let searchItem = window.toolbar?.items.first(where: { $0.itemIdentifier == .searchItem }) as? NSSearchToolbarItem + { + window.makeFirstResponder(searchItem.searchField) + } + } +} + +// MARK: - Schedule + +class ScheduleSearchCoordinator: GlobalSearchCoordinator { + override fileprivate func configureFilters(events: [Event], focuses: [Focus], tracks: [Track], sessionTypes: [String]) { + var filters = makeScheduleFilters(sessionTypes: sessionTypes, focuses: focuses, tracks: tracks) + if let currentControllerState = IntermediateFiltersStructure.from(existingFilters: tabState.effectiveFilters) { + filters.apply(currentControllerState) + } else { + filters.apply(restorationFiltersState?.scheduleTab) + } + tabState.effectiveFilters = filters.all + tabState.updatePredicate(.configurationChange) + super.configureFilters(events: events, focuses: focuses, tracks: tracks, sessionTypes: sessionTypes) + } + + func makeScheduleFilters(sessionTypes: [String], focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { + // Schedule Filters Configuration + let eventOptions = sessionTypes.map { FilterOption(title: $0, value: $0) } + let eventFilter = MultipleChoiceFilter( + id: .event, + modelKey: "rawSessionType", + collectionKey: "session.instances", + options: eventOptions, + emptyTitle: "All Content" + ) + let textualFilter = TextualFilter(identifier: .text, value: nil) { value in + let modelKeys = ["title"] + + guard let value = value else { return nil } + guard value.count >= 2 else { return nil } + + if Int(value) != nil { + return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(SessionInstance.session.number), value) + } + + var subpredicates = modelKeys.map { key -> NSPredicate in + return NSPredicate(format: "session.\(key) CONTAINS[cd] %@", value) + } + + let keywords = NSPredicate(format: "ANY keywords.name CONTAINS[cd] %@", value) + subpredicates.append(keywords) + + if Preferences.shared.searchInBookmarks { + let bookmarks = NSPredicate(format: "ANY session.bookmarks.body CONTAINS[cd] %@", value) + subpredicates.append(bookmarks) + } + + if Preferences.shared.searchInTranscripts { + let transcripts = NSPredicate(format: "session.transcriptText CONTAINS[cd] %@", value) + subpredicates.append(transcripts) + } + + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) + } + + return makeFilters(keyPathPrefix: "session.", eventFilter: eventFilter, textualFilter: textualFilter, focuses: focuses, tracks: tracks) + } +} + +// MARK: - Videos + +class VideosSearchCoordinator: GlobalSearchCoordinator { + override fileprivate func configureFilters(events: [Event], focuses: [Focus], tracks: [Track], sessionTypes: [String]) { + var filters = makeVideoFilters(events: events, focuses: focuses, tracks: tracks) + if let currentControllerState = IntermediateFiltersStructure.from(existingFilters: tabState.effectiveFilters) { + filters.apply(currentControllerState) + } else { + filters.apply(restorationFiltersState?.videosTab) + } + tabState.effectiveFilters = filters.all + tabState.updatePredicate(.configurationChange) + } + + func makeVideoFilters(events: [Event], focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { + let eventOptionsByType = events + .map { FilterOption(title: $0.name, value: $0.identifier) } + .grouped(by: \.isWWDCEvent) + + // Add a separator between WWDC and non-WWDC events. + let eventOptions = eventOptionsByType[true, default: []] + [.separator] + eventOptionsByType[false, default: []] + + let eventFilter = MultipleChoiceFilter( + id: .event, + modelKey: "eventIdentifier", + options: eventOptions, + emptyTitle: "All Content" + ) + let textualFilter = TextualFilter(identifier: .text, value: nil) { value in + let modelKeys = ["title"] + + guard let value = value else { return nil } + guard value.count >= 2 else { return nil } + + if Int(value) != nil { + return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(Session.number), value) + } + + var subpredicates = modelKeys.map { key -> NSPredicate in + return NSPredicate(format: "\(key) CONTAINS[cd] %@", value) + } + + let keywords = NSPredicate(format: "SUBQUERY(instances, $instances, ANY $instances.keywords.name CONTAINS[cd] %@).@count > 0", value) + subpredicates.append(keywords) + + if Preferences.shared.searchInBookmarks { + let bookmarks = NSPredicate(format: "ANY bookmarks.body CONTAINS[cd] %@", value) + subpredicates.append(bookmarks) + } + + if Preferences.shared.searchInTranscripts { + let transcripts = NSPredicate(format: "transcriptText CONTAINS[cd] %@", value) + subpredicates.append(transcripts) + } + + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) + } + + return makeFilters(eventFilter: eventFilter, textualFilter: textualFilter, focuses: focuses, tracks: tracks) + } +} + +// MARK: - Private helpers + +private extension GlobalSearchCoordinator { + func makeFilters(keyPathPrefix: String = "", eventFilter: MultipleChoiceFilter, textualFilter: TextualFilter, focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { + let focusOptions = focuses.map { FilterOption(title: $0.name, value: $0.name) } + let focusFilter = MultipleChoiceFilter( + id: .focus, + modelKey: "name", + collectionKey: "\(keyPathPrefix)focuses", + options: focusOptions, + emptyTitle: "All Platforms" + ) + + let trackOptions = tracks.map { FilterOption(title: $0.name, value: $0.name) } + let trackFilter = MultipleChoiceFilter( + id: .track, + modelKey: "\(keyPathPrefix)trackName", + options: trackOptions, + emptyTitle: "All Topics" + ) + + let favoriteFilter = OptionalToggleFilter( + id: .isFavorite, + onPredicate: NSPredicate(format: "SUBQUERY(\(keyPathPrefix)favorites, $favorite, $favorite.isDeleted == false).@count > 0"), + offPredicate: NSPredicate(format: "SUBQUERY(\(keyPathPrefix)favorites, $favorite, $favorite.isDeleted == false).@count == 0") + ) + + let downloadedFilter = OptionalToggleFilter( + id: .isDownloaded, + onPredicate: NSPredicate(format: "\(keyPathPrefix)isDownloaded == true"), + offPredicate: NSPredicate(format: "\(keyPathPrefix)isDownloaded == false") + ) + + let smallPositionPred = NSPredicate(format: "SUBQUERY(\(keyPathPrefix)progresses, $progress, $progress.relativePosition < \(Constants.watchedVideoRelativePosition)).@count > 0") + let noPositionPred = NSPredicate(format: "\(keyPathPrefix)progresses.@count == 0") + let unwatchedFilter = OptionalToggleFilter( + id: .isUnwatched, + onPredicate: NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSCompoundPredicate(notPredicateWithSubpredicate: smallPositionPred), + NSCompoundPredicate(notPredicateWithSubpredicate: noPositionPred) + ]), + offPredicate: NSCompoundPredicate(orPredicateWithSubpredicates: [smallPositionPred, noPositionPred]) + ) + + let bookmarksFilter = OptionalToggleFilter( + id: .hasBookmarks, + onPredicate: NSPredicate(format: "SUBQUERY(\(keyPathPrefix)bookmarks, $bookmark, $bookmark.isDeleted == false).@count > 0"), + offPredicate: NSPredicate(format: "SUBQUERY(\(keyPathPrefix)bookmarks, $bookmark, $bookmark.isDeleted == false).@count == 0") + ) + + return IntermediateFiltersStructure( + textual: textualFilter, + event: eventFilter, + platform: focusFilter, + track: trackFilter, + isFavorite: favoriteFilter, + isDownloaded: downloadedFilter, + isUnwatched: unwatchedFilter, + hasBookmarks: bookmarksFilter + ) + } +} + +private extension FilterOption { + var isWWDCEvent: Bool { title.uppercased().hasPrefix("WWDC") } +} diff --git a/WWDC/Tahoe/GlobalSearchTabState.swift b/WWDC/Tahoe/GlobalSearchTabState.swift new file mode 100644 index 00000000..b75d4282 --- /dev/null +++ b/WWDC/Tahoe/GlobalSearchTabState.swift @@ -0,0 +1,37 @@ +// +// GlobalSearchTabState.swift +// WWDC +// +// Created by luca on 01.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import Combine +import ConfCore +import OSLog +import RealmSwift + +struct GlobalSearchTabState { + let additionalPredicates: [NSPredicate] + private(set) var filterPredicate: FilterPredicate { + willSet { + GlobalSearchCoordinator.log.debug("New predicate: \(newValue.predicate?.description ?? "nil", privacy: .public)") + } + } + + var effectiveFilters: [FilterType] = [] + private var currentPredicate: NSPredicate? { + let filters = effectiveFilters + guard filters.contains(where: { !$0.isEmpty }) || !additionalPredicates.isEmpty else { + return nil + } + let subpredicates = filters.compactMap { $0.predicate } + additionalPredicates + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates) + return predicate + } + + mutating func updatePredicate(_ reason: FilterChangeReason) { + filterPredicate = .init(predicate: currentPredicate, changeReason: reason) + } +} diff --git a/WWDC/Tahoe/InteractionEffects.swift b/WWDC/Tahoe/InteractionEffects.swift new file mode 100644 index 00000000..452dbc02 --- /dev/null +++ b/WWDC/Tahoe/InteractionEffects.swift @@ -0,0 +1,31 @@ +// +// HoverEffect.swift +// WWDC +// +// Created by luca on 10.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +extension View { + func hoverEffect(scale: Double = 1.2) -> some View { + modifier(HoverEffect(hoverScale: scale)) + } +} + +struct HoverEffect: ViewModifier { + @State private var isHovered: Bool = false + var hoverScale: Double + + func body(content: Content) -> some View { + content + .scaleEffect(isHovered ? hoverScale : 1) + .animation(.smooth, value: isHovered) + .onHover { isHovering in + withAnimation { + isHovered = isHovering + } + } + } +} diff --git a/WWDC/Tahoe/NewAppCoordinator.swift b/WWDC/Tahoe/NewAppCoordinator.swift new file mode 100644 index 00000000..89981c53 --- /dev/null +++ b/WWDC/Tahoe/NewAppCoordinator.swift @@ -0,0 +1,555 @@ +// +// NewAppCoordinator.swift +// WWDC +// +// Created by luca on 30.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AVFoundation +import Cocoa +import Combine +import ConfCore +import OSLog +import PlayerUI +import RealmSwift +import SwiftUI + +@available(macOS 26.0, *) +extension EnvironmentValues { + var coordinator: NewAppCoordinator? { + self[KeyCoordinator.self] + } + + fileprivate struct KeyCoordinator: SwiftUICore.EnvironmentKey { + static var defaultValue: NewAppCoordinator? { + (NSApp.delegate as? AppDelegate)?.coordinator as? NewAppCoordinator + } + } +} + +@available(macOS 26.0, *) +extension Observable { + var coordinator: NewAppCoordinator? { + EnvironmentValues.KeyCoordinator.defaultValue + } +} + +@available(macOS 26.0, *) +final class NewAppCoordinator: WWDCCoordinator { + nonisolated static let log = makeLogger() + nonisolated static let signposter: OSSignposter = makeSignposter() + + private lazy var cancellables = Set() + + var liveObserver: LiveObserver + + var storage: Storage + var syncEngine: SyncEngine + + // - Top level controllers + var windowController: WWDCWindowControllerObject + var tabController: ReplaceableSplitViewController + var searchCoordinator: GlobalSearchCoordinator? { + switch activeTab { + case .explore: + return nil + case .schedule: + return scheduleSearchCoordinator + case .videos: + return videosSearchCoordinator + } + } + + // - The 3 tabs + let exploreViewModel: NewExploreViewModel + + let scheduleSearchCoordinator: GlobalSearchCoordinator + let scheduleTable: NewSessionsTableViewController + + let videosSearchCoordinator: GlobalSearchCoordinator + let videosTable: NewSessionsTableViewController + var currentShelfViewController: ShelfViewController? + + var currentPlayerController: VideoPlayerViewController? + + var currentActivity: NSUserActivity? + + var activeTab: MainWindowTab = .schedule + + /// The tab that "owns" the current player (the one that was active when the "play" button was pressed) + var playerOwnerTab: MainWindowTab? + + /// The session that "owns" the current player (the one that was selected on the active tab when "play" was pressed) + @Published + var playerOwnerSessionIdentifier: String? + + /// Whether we're currently in the middle of a player context transition + var isTransitioningPlayerContext = false + + /// Whether we were playing the video when a clip sharing session begin, to restore state later. + var wasPlayingWhenClipSharingBegan = false + + /// The list controller for the active tab + var currentTable: NewSessionsTableViewController? { + switch activeTab { + case .explore: + return nil + case .schedule: + return scheduleTable + case .videos: + return videosTable + } + } + + var exploreTabLiveSession: AnyPublisher { + let liveInstances = storage.realm.objects(SessionInstance.self) + .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") + .sorted(byKeyPath: "startTime", ascending: false) + + return liveInstances.collectionPublisher + .map { $0.toArray().first?.session } + .map { SessionViewModel(session: $0, instance: $0?.instances.first, track: nil, style: .schedule) } + .replaceErrorWithEmpty() + .eraseToAnyPublisher() + } + + /// The session that is currently selected on the videos tab (observable) + var videosSelectedSessionViewModel: SessionViewModel? { videosTable.selectedSession } + + /// The session that is currently selected on the schedule tab (observable) + var scheduleSelectedSessionViewModel: SessionViewModel? { scheduleTable.selectedSession } + + /// The selected session's view model, regardless of which tab it is selected in + var activeTabSelectedSessionViewModel: SessionViewModel? { detailViewModel.session } + var detailViewModel: SessionItemViewModel + + /// The viewModel for the current playback session + var currentPlaybackViewModel: PlaybackViewModel? { + didSet { + observeNowPlayingInfo() + } + } + + private lazy var downloadMonitor = DownloadedContentMonitor() + + @MainActor + init(windowController: WWDCWindowControllerObject, storage: Storage, syncEngine: SyncEngine) { + let signpostState = Self.signposter.beginInterval("initialization", id: Self.signposter.makeSignpostID(), "begin init") + self.storage = storage + self.syncEngine = syncEngine + + liveObserver = LiveObserver(dateProvider: today, storage: storage, syncEngine: syncEngine) + + // Primary UI Initialization + + // Explore + let provider = ExploreTabProvider(storage: storage) + exploreViewModel = NewExploreViewModel(provider: provider) + + videosSearchCoordinator = VideosSearchCoordinator( + storage, + tabState: GlobalSearchTabState( + additionalPredicates: [Session.videoPredicate], + filterPredicate: .init(predicate: nil, changeReason: .initialValue) + ) + ) + videosTable = NewSessionsTableViewController( + searchCoordinator: videosSearchCoordinator, + rowProvider: VideosSessionRowProvider( + tracks: storage.tracks, + filterPredicate: videosSearchCoordinator.$predicate, + playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue + ), + initialSelection: Preferences.shared.selectedVideoItemIdentifier.map(SessionIdentifier.init) + ) + scheduleSearchCoordinator = ScheduleSearchCoordinator( + storage, + tabState: GlobalSearchTabState( + additionalPredicates: [ + NSPredicate(format: "ANY session.event.isCurrent == true"), + NSPredicate(format: "session.instances.@count > 0") + ], + filterPredicate: .init(predicate: nil, changeReason: .initialValue) + ) + ) + scheduleTable = NewSessionsTableViewController( + searchCoordinator: scheduleSearchCoordinator, + rowProvider: ScheduleSessionRowProvider( + scheduleSections: storage.scheduleSections, + filterPredicate: scheduleSearchCoordinator.$predicate, + playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue + ), + initialSelection: Preferences.shared.selectedScheduleItemIdentifier.map(SessionIdentifier.init) + ) + detailViewModel = .init() + tabController = ReplaceableSplitViewController(windowController: windowController, exploreViewModel: exploreViewModel, scheduleTable: scheduleTable, videosTable: videosTable, detailViewModel: detailViewModel) + + _playerOwnerSessionIdentifier = .init(initialValue: nil) + self.windowController = windowController + + NSApp.isAutomaticCustomizeTouchBarMenuItemEnabled = true + + let buttonsController = TitleBarButtonsViewController( + downloadManager: .shared, + storage: storage + ) + windowController.titleBarViewController.statusViewController = buttonsController + + buttonsController.handleSharePlayClicked = { [weak self] in + DispatchQueue.main.async { self?.startSharePlay() } + } + + MediaDownloadManager.shared.activate() + downloadMonitor.activate(with: storage) + + Self.signposter.endInterval("initialization", signpostState, "end init") + } + + // MARK: - Start up + + @MainActor + func startup() { + setupBindings() + + NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification).sink { _ in + self.saveApplicationState() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .RefreshPeriodicallyPreferenceDidChange).sink { _ in + self.resetAutorefreshTimer() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .PreferredTranscriptLanguageDidChange).receive(on: DispatchQueue.main).sink { + self.preferredTranscriptLanguageDidChange($0) + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .SyncEngineDidSyncSessionsAndSchedule).receive(on: DispatchQueue.main).sink { [weak self] note in + guard let self else { return } + + guard self.checkSyncEngineOperationSucceededAndShowError(note: note) == true else { return } + + self.downloadMonitor.syncWithFileSystem() + }.store(in: &cancellables) + NotificationCenter.default.publisher(for: .WWDCEnvironmentDidChange).receive(on: DispatchQueue.main).sink { _ in + self.refresh(nil) + }.store(in: &cancellables) + + liveObserver.start() + + DispatchQueue.main.async { self.configureSharePlayIfSupported() } + + refresh(nil) + windowController.contentViewController = tabController + windowController.showWindow(self) + tabController.setActiveTab(Preferences.shared.activeTab) + + [videosTable, scheduleTable].forEach { + $0.sessionRowProvider.startup() + } + if Arguments.showPreferences { + showPreferences(nil) + } + } + + private func setupBindings() { + Publishers.CombineLatest3( + tabController.activeTabPublisher(for: MainWindowTab.self), + videosTable.$selectedSession, + scheduleTable.$selectedSession + ).receive(on: DispatchQueue.main) + .sink { [weak self] activeTab, newVideoModel, newScheduleModel in + guard let self else { return } + self.activeTab = activeTab + + switch activeTab { + case .schedule: + detailViewModel.session = newScheduleModel + case .videos: + detailViewModel.session = newVideoModel + default: + detailViewModel.session = nil + } + + updateShelfBasedOnSelectionChange() + updateCurrentActivity(with: activeTabSelectedSessionViewModel) + } + .store(in: &cancellables) + } + + func checkSyncEngineOperationSucceededAndShowError(note: Notification) -> Bool { + if let error = note.object as? APIError { + switch error { + case .adapter, .unknown: + WWDCAlert.show(with: error) + case .http: + break + } + } else if let error = note.object as? Error { + WWDCAlert.show(with: error) + } else { + return true + } + + return false + } + + func selectSessionOnAppropriateTab(with viewModel: SessionViewModel) { + if currentTable?.canDisplay(session: viewModel) == true { + currentTable?.select(session: viewModel, removingFiltersIfNeeded: true) + return + } + // always to videos + videosTable.select(session: viewModel, removingFiltersIfNeeded: true) + tabController.setActiveTab(MainWindowTab.videos) + } + + @discardableResult func receiveNotification(with userInfo: [String: Any]) -> Bool { + let userDataSyncEngineHandled: Bool + + #if ICLOUD + userDataSyncEngineHandled = syncEngine.userDataSyncEngine?.processSubscriptionNotification(with: userInfo) == true + #else + userDataSyncEngineHandled = false + #endif + + return userDataSyncEngineHandled || + liveObserver.processSubscriptionNotification(with: userInfo) + } + + // MARK: - Now playing info + + private var nowPlayingInfoBag: Set = [] + + private func observeNowPlayingInfo() { + nowPlayingInfoBag = [] + + currentPlaybackViewModel?.$nowPlayingInfo.sink(receiveValue: { [weak self] _ in + self?.publishNowPlayingInfo() + }).store(in: &nowPlayingInfoBag) + } + + // MARK: - State restoration + + private func saveApplicationState() { + Preferences.shared.activeTab = activeTab + Preferences.shared.selectedScheduleItemIdentifier = scheduleSelectedSessionViewModel?.identifier + Preferences.shared.selectedVideoItemIdentifier = videosSelectedSessionViewModel?.identifier + let uiState = WWDCFiltersState( + scheduleTab: WWDCFiltersState.Tab(filters: scheduleSearchCoordinator.effectiveFilters), + videosTab: WWDCFiltersState.Tab(filters: videosSearchCoordinator.effectiveFilters) + ) + Preferences.shared.filtersState = (try? JSONEncoder().encode(uiState)) + .flatMap { String(bytes: $0, encoding: .utf8) } + } + + // MARK: - Deep linking + + func handle(link: DeepLink) { + if link.isForCurrentYear { + tabController.setActiveTab(MainWindowTab.schedule) + scheduleTable.select(session: link) + } else { + tabController.setActiveTab(MainWindowTab.videos) + videosTable.select(session: link) + } + } + + func applyFilter(state: WWDCFiltersState) { + tabController.setActiveTab(MainWindowTab.videos) + + DispatchQueue.main.async { + self.videosSearchCoordinator.apply(for: .init(filters: self.videosSearchCoordinator.effectiveFilters)) + } + } + + // MARK: - Preferences + + private lazy var preferencesCoordinator: PreferencesCoordinator = .init(syncEngine: syncEngine) + + func showPreferences(_ sender: Any?) { + #if ICLOUD + preferencesCoordinator.userDataSyncEngine = syncEngine.userDataSyncEngine + #endif + + preferencesCoordinator.show() + } + + // MARK: - About window + + fileprivate lazy var aboutWindowController: AboutWindowController = { + var aboutWC = AboutWindowController(infoText: ContributorsFetcher.shared.infoText) + + ContributorsFetcher.shared.infoTextChangedCallback = { [unowned self] newText in + self.aboutWindowController.infoText = newText + } + + ContributorsFetcher.shared.load() + + return aboutWC + }() + + func showAboutWindow() { + aboutWindowController.showWindow(nil) + } + + func showExplore() { + tabController.setActiveTab(MainWindowTab.explore) + } + + func showSchedule() { + tabController.setActiveTab(MainWindowTab.schedule) + } + + func showVideos() { + tabController.setActiveTab(MainWindowTab.videos) + } + + // MARK: - Refresh + + /// Used to prevent the refresh system from being spammed. Resetting + /// NSBackgroundActivitySchedule can result in the scheduled activity happening immediately + /// especially if the `interval` is sufficiently low. + private var lastRefresh = Date.distantPast + + func refresh(_ sender: Any?) { + guard !NSApp.isPreview else { return } + + let now = Date() + guard now.timeIntervalSince(lastRefresh) > 5 else { return } + lastRefresh = now + + DispatchQueue.main.async { + self.syncEngine.syncConfiguration() + + self.syncEngine.syncContent() + + self.liveObserver.refresh() + + if self.autorefreshActivity == nil + || (sender as? NSBackgroundActivityScheduler) !== self.autorefreshActivity + { + self.resetAutorefreshTimer() + } + } + } + + private var autorefreshActivity: NSBackgroundActivityScheduler? + + func makeAutorefreshActivity() -> NSBackgroundActivityScheduler { + let activityScheduler = NSBackgroundActivityScheduler(identifier: "io.wwdc.autorefresh.backgroundactivity") + activityScheduler.interval = Constants.autorefreshInterval + activityScheduler.repeats = true + activityScheduler.qualityOfService = .utility + activityScheduler.schedule { [weak self] completion in + DispatchQueue.main.async { + self?.refresh(self?.autorefreshActivity) + } + completion(.finished) + } + + return activityScheduler + } + + private func resetAutorefreshTimer() { + autorefreshActivity?.invalidate() + autorefreshActivity = Preferences.shared.refreshPeriodically ? makeAutorefreshActivity() : nil + } + + // MARK: - Language preference + + private func preferredTranscriptLanguageDidChange(_ note: Notification) { + guard let code = note.object as? String else { return } + + syncEngine.transcriptLanguage = code + } + + // MARK: - SharePlay + + private var sharePlayConfigured = false + + func configureSharePlayIfSupported() { + let log = ConfCore.makeLogger(subsystem: SharePlayManager.defaultLoggerConfig().subsystem, category: String(describing: AppCoordinator.self)) + + guard !sharePlayConfigured else { return } + sharePlayConfigured = true + + SharePlayManager.shared.$state.sink { [weak self] state in + guard let self = self else { return } + + guard case .session(let session) = state else { return } + + self.currentPlayerController?.player?.playbackCoordinator.coordinateWithSession(session) + }.store(in: &cancellables) + + SharePlayManager.shared.$currentActivity.sink { [weak self] activity in + guard let self = self, let activity = activity else { return } + + guard let wwdcSession = self.storage.session(with: activity.sessionID) else { + log.error("Couldn't find the session with ID \(activity.sessionID, privacy: .public)") + return + } + + guard let viewModel = SessionViewModel(session: wwdcSession) else { + log.error("Couldn't create the view model for session \(activity.sessionID, privacy: .public)") + return + } + + self.selectSessionOnAppropriateTab(with: viewModel) + + DispatchQueue.main.async { + self.currentShelfViewController?.play(nil) + } + }.store(in: &cancellables) + + SharePlayManager.shared.startObservingState() + } + + func activePlayerDidChange(to newPlayer: AVPlayer?) { + log.debug("\(#function, privacy: .public)") + + guard case .session(let session) = SharePlayManager.shared.state else { return } + + log.debug("Attaching new player to active SharePlay session") + + newPlayer?.playbackCoordinator.coordinateWithSession(session) + } + + func startSharePlay() { + if case .session = SharePlayManager.shared.state { + let alert = NSAlert() + alert.messageText = "Leave SharePlay?" + alert.informativeText = "Are you sure you'd like to leave this SharePlay session?" + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Leave") + + if alert.runModal() == .alertSecondButtonReturn { + SharePlayManager.shared.leaveActivity() + } + + return + } + + guard let viewModel = videosSelectedSessionViewModel else { + let alert = NSAlert() + alert.messageText = "Select a Session" + alert.informativeText = "Please select the session you'd like to watch together, then start SharePlay." + alert.addButton(withTitle: "OK") + alert.runModal() + return + } + + SharePlayManager.shared.startActivity(for: viewModel.session) + } + + // MARK: - Shelf + + func shelf(for tab: MainWindowTab) -> ShelfViewController? { + currentShelfViewController + } + + func select(session: any SessionIdentifiable, removingFiltersIfNeeded: Bool) { + currentTable?.select(session: session, removingFiltersIfNeeded: removingFiltersIfNeeded) + } + + func showClipUI() { + currentShelfViewController?.showClipUI() + } +} diff --git a/WWDC/Tahoe/NewMainWindowController.swift b/WWDC/Tahoe/NewMainWindowController.swift new file mode 100644 index 00000000..7465bae5 --- /dev/null +++ b/WWDC/Tahoe/NewMainWindowController.swift @@ -0,0 +1,145 @@ +// +// NewMainWindowController.swift +// WWDC +// +// Created by luca on 30.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import Combine + +@available(macOS 26.0, *) +final class NewMainWindowController: NewWWDCWindowController { + weak var touchBarProvider: NSResponder? { + didSet { + touchBar = nil + } + } + + override func loadWindow() { + let mask: NSWindow.StyleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] + let window = NSWindow(contentRect: MainWindowController.defaultRect, styleMask: mask, backing: .buffered, defer: false) + + window.title = "WWDC" + + window.center() + + window.identifier = .mainWindow + window.setFrameAutosaveName("main") + window.minSize = NSSize(width: 1060, height: Constants.minimumWindowHeight) + window.styleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] + window.isMovableByWindowBackground = false + window.titleVisibility = .hidden + window.tabbingMode = .disallowed + + self.window = window + } + + @objc func performFindPanelAction(_ sender: Any) { + guard let searchItem = window?.toolbar?.items.first(where: { $0.itemIdentifier == .searchItem }) as? NSSearchToolbarItem else { + return + } + searchItem.beginSearchInteraction() + } + + override func makeTouchBar() -> NSTouchBar? { + return touchBarProvider?.makeTouchBar() + } +} + +@available(macOS 26.0, *) +extension NewMainWindowController: NSToolbarDelegate { + func setupToolbar(tab: MainWindowTab = .explore) { + guard let window else { + return + } + let toolbar = NSToolbar(identifier: "LiquidToolbar-\(tab.rawValue)") + + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsDisplayModeCustomization = false + toolbar.allowsUserCustomization = false + toolbar.centeredItemIdentifiers = [.tabSelectionItem] + window.toolbar = toolbar + } + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier) + toolbarItem.autovalidates = false + switch itemIdentifier { + case .searchItem: + let item = NSSearchToolbarItem(itemIdentifier: itemIdentifier) + item.resignsFirstResponderWithCancel = true + return item + case .filterItem: + let item = NSMenuToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "line.3.horizontal.decrease.circle", accessibilityDescription: "Filter") + item.showsIndicator = false + item.menu.addItem(withTitle: "Action 1", action: nil, keyEquivalent: "") + item.toolTip = "Filter" + return item + case .tabSelectionItem: + let segmentControl = NSSegmentedControl() + segmentControl.segmentCount = MainWindowTab.allCases.count + segmentControl.trackingMode = .selectOne + for (idx, tab) in MainWindowTab.allCases.enumerated() { + let image = tab.toolbarItemImage + segmentControl.setImage(image, forSegment: idx) + segmentControl.setLabel(image?.accessibilityDescription ?? "", forSegment: idx) + } + segmentControl.target = self + segmentControl.action = #selector(selectTabControl) + segmentControl.selectedSegment = coordinator?.activeTab.rawValue ?? 0 + toolbarItem.view = segmentControl + toolbarItem.title = "Explore|Schedule|Videos" + toolbarItem.backgroundTintColor = .clear + case .downloadItem: + toolbarItem.image = NSImage(systemSymbolName: "arrow.down", accessibilityDescription: "Dowloads") + toolbarItem.toolTip = "Downloads" + toolbarItem.target = self + toolbarItem.action = #selector(toggleDownloadPanel) + default: + break // won't go here since all allowed custom items are handled above + } + return toolbarItem + } + + func toolbar(_ toolbar: NSToolbar, itemIdentifier: NSToolbarItem.Identifier, canBeInsertedAt index: Int) -> Bool { + return true + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [ + .flexibleSpace, + .filterItem, + .sidebarTrackingSeparator, + .tabSelectionItem, + .downloadItem, + .flexibleSpace, + .searchItem + ] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } +} + +@available(macOS 26.0, *) +private extension NewMainWindowController { + @objc func toggleDownloadPanel(_ item: NSToolbarItem) {} + + @objc func selectTabControl(_ control: NSSegmentedControl) { + if let tab = MainWindowTab(rawValue: control.selectedSegment) { + coordinator?.tabController.setActiveTab(tab) + } + } +} + +@available(macOS 26.0, *) +private extension NewMainWindowController { + var coordinator: (any WWDCCoordinator)? { + (NSApp.delegate as? AppDelegate)?.coordinator + } +} diff --git a/WWDC/Tahoe/NewSessionsTableViewController.swift b/WWDC/Tahoe/NewSessionsTableViewController.swift new file mode 100644 index 00000000..6383b352 --- /dev/null +++ b/WWDC/Tahoe/NewSessionsTableViewController.swift @@ -0,0 +1,758 @@ +// +// NewSessionsTableViewController.swift +// WWDC +// +// Created by luca on 30.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Cocoa +import Combine +import ConfCore +import Observation +import OSLog +import RealmSwift +import SwiftUI + +// MARK: - Sessions Table View Controller + +@available(macOS 26.0, *) +class NewSessionsTableViewController: NSViewController, NSMenuItemValidation, Logging { + static var log = makeLogger() + + private lazy var cancellables: Set = [] + + weak var delegate: SessionsTableViewControllerDelegate? + + let searchCoordinator: GlobalSearchCoordinator + init(searchCoordinator: GlobalSearchCoordinator, rowProvider: SessionRowProvider, initialSelection: SessionIdentifiable?) { + self.searchCoordinator = searchCoordinator + var config = Self.defaultLoggerConfig() + config.category += ": \(String(reflecting: type(of: rowProvider)))" + Self.log = Self.makeLogger(config: config) + self.sessionRowProvider = rowProvider + self.stateRestorationSelection = initialSelection + + super.init(nibName: nil, bundle: nil) + + identifier = NSUserInterfaceItemIdentifier(rawValue: "videosList") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var searchHeader: NSView = NSHostingView(rootView: ListContentFilterAccessoryView().environment(searchCoordinator)) + + var scrollTopConstraint: NSLayoutConstraint! + override func loadView() { + super.loadView() + view.addSubview(searchHeader) + searchHeader.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + searchHeader.topAnchor.constraint(equalTo: view.topAnchor), + searchHeader.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchHeader.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + searchHeader.isHidden = true + + scrollView.frame = view.bounds + tableView.frame = view.bounds + view.addSubview(scrollView) + + scrollView.contentView.automaticallyAdjustsContentInsets = true + + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + scrollTopConstraint = scrollView.topAnchor.constraint(equalTo: view.topAnchor) + scrollTopConstraint.isActive = true + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.dataSource = self + tableView.delegate = self + + setupContextualMenu() + if let rows = sessionRowProvider.rows { + updateWith(rows: rows, animated: true) + } + + sessionRowProvider + .rowsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.updateWith(rows: $0, animated: true) + } + .store(in: &cancellables) + searchCoordinator.updatePredicate(.configurationChange) // trigger row updates + } + + override func viewDidAppear() { + super.viewDidAppear() + + // This allows using the arrow keys to navigate + view.window?.makeFirstResponder(tableView) + prepareForDisplayingFilterItems() + } + + override func viewWillDisappear() { + super.viewWillDisappear() + prepareForHidingFilterItems() + } + + override func viewWillLayout() { + super.viewWillLayout() + prepareForDisplayingFilterItems() + // tracking observations + guard searchCoordinator.searchTarget == .sessions else { + filterItem?.badge = nil + filterItem?.showsIndicator = false + return + } + let count = searchCoordinator.effectiveFilters.filter { !$0.isEmpty }.count + filterItem?.badge = count > 0 ? .count(count) : nil + filterItem?.showsIndicator = count > 0 + } + + // MARK: - Selection + + @Published + var selectedSession: SessionViewModel? + /// The state restoration selection will be applied on 1st row display and then cleared + private var stateRestorationSelection: SessionIdentifiable? + /// The pending selection will be selected on the next update + private var pendingSelection: SessionIdentifiable? + + private func selectSessionImmediately(with identifier: SessionIdentifiable) { + guard let index = displayedRows.firstIndex(where: { $0.represents(session: identifier) }) else { + log.debug("Can't select session \(identifier.sessionIdentifier)") + return + } + + tableView.scrollRowToCenter(index) + tableView.selectRowIndexes(IndexSet([index]), byExtendingSelection: false) + } + + func select(session: SessionIdentifiable, removingFiltersIfNeeded: Bool = true) { + let needsToClearSearchToAllowSelection = removingFiltersIfNeeded && !isSessionVisible(for: session) && canDisplay(session: session) + + if needsToClearSearchToAllowSelection { + pendingSelection = session + } else { + selectSessionImmediately(with: session) + } + } + + /// Select and scroll to the session/get-together/lab that is "upcoming" and in your current filters + /// We do not clear filters, so if your schedule view is just showing videos, it'll scroll to the video that will be released next + func scrollToToday() { + sessionRowProvider.sessionRowIdentifierForToday().flatMap { select(session: $0, removingFiltersIfNeeded: false) } + } + + private func updateWith(rows: SessionRows, animated: Bool) { + guard viewIfLoaded != nil else { + return + } + let rowsToDisplay: [SessionRow] + rowsToDisplay = rows.filtered + + guard performInitialRowDisplayIfNeeded(displaying: rowsToDisplay, allRows: rows.all) else { + log.debug("Performed initial row display with [\(rowsToDisplay.count)] rows") + return + } + + setDisplayedRows(rowsToDisplay, animated: animated) + } + + // MARK: - Updating the Displayed Rows + + let sessionRowProvider: SessionRowProvider + + private var displayedRows: [SessionRow] = [] + + private lazy var displayedRowsLock = DispatchQueue(label: "io.wwdc.sessiontable.displayedrows.lock\(self.hashValue)", qos: .userInteractive) + + @Published + private(set) var hasPerformedInitialRowDisplay = false + + private func performInitialRowDisplayIfNeeded(displaying rows: [SessionRow], allRows: [SessionRow]) -> Bool { + guard !hasPerformedInitialRowDisplay else { return true } + displayedRowsLock.suspend() + + displayedRows = rows + + tableView.reloadData() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + + if let deferredSelection = self.stateRestorationSelection { + self.stateRestorationSelection = nil + self.selectSessionImmediately(with: deferredSelection) + } + + // Ensure an initial selection + if self.tableView.selectedRow == -1, + let defaultIndex = rows.firstIndex(where: { $0.isSession }) + { + self.tableView.selectRowIndexes(IndexSet(integer: defaultIndex), byExtendingSelection: false) + } + + self.scrollView.alphaValue = 1 + self.tableView.allowsEmptySelection = false + } completionHandler: { + self.displayedRowsLock.resume() + self.hasPerformedInitialRowDisplay = true + } + + return false + } + + private func setDisplayedRows(_ newValue: [SessionRow], animated: Bool) { + // Dismiss the menu when the displayed rows are about to change otherwise it will crash + tableView.menu?.cancelTrackingWithoutAnimation() + + displayedRowsLock.async { + let sessionToSelect = self.pendingSelection + self.pendingSelection = nil + let oldValue = self.displayedRows + + // Same elements, same order: https://github.com/apple/swift/blob/master/stdlib/public/core/Arrays.swift.gyb#L2203 + if oldValue == newValue { return } + + let oldRowsSet = Set(oldValue.enumerated().map { IndexedSessionRow(sessionRow: $1, index: $0) }) + let newRowsSet = Set(newValue.enumerated().map { IndexedSessionRow(sessionRow: $1, index: $0) }) + assert(newRowsSet.count == newValue.count) + assert(oldRowsSet.count == oldValue.count) + + let removed = oldRowsSet.subtracting(newRowsSet) + let added = newRowsSet.subtracting(oldRowsSet) + + let removedIndexes = IndexSet(removed.map { $0.index }) + let addedIndexes = IndexSet(added.map { $0.index }) + + // Only reload rows if their relative positioning changes. This prevents + // cell contents from flashing when cells are unnecessarily reloaded + var needReloadedIndexes = IndexSet() + + let sortedOldRows = oldRowsSet.intersection(newRowsSet).sorted(by: { row1, row2 -> Bool in + return row1.index < row2.index + }) + + let sortedNewRows = newRowsSet.intersection(oldRowsSet).sorted(by: { row1, row2 -> Bool in + return row1.index < row2.index + }) + + for (oldSessionRowIndex, newSessionRowIndex) in zip(sortedOldRows, sortedNewRows) where oldSessionRowIndex.sessionRow != newSessionRowIndex.sessionRow { + needReloadedIndexes.insert(newSessionRowIndex.index) + } + + self.log.trace("setDisplayedRows: removed[\(removedIndexes.map { "\($0)" }.joined(separator: ",").count, privacy: .public)] added[\(addedIndexes.map { "\($0)" }.joined(separator: ",").count, privacy: .public)] reload[\(needReloadedIndexes.map { "\($0)" }.joined(separator: ",").count, privacy: .public)]") + + DispatchQueue.main.sync { + var selectedIndexes = IndexSet() + if let sessionToSelect, + let overrideIndex = newValue.firstIndex(where: { $0.sessionViewModel?.identifier == sessionToSelect.sessionIdentifier }) + { + selectedIndexes.insert(overrideIndex) + } else { + // Preserve selected rows if possible + let previouslySelectedRows = self.tableView.selectedRowIndexes.compactMap { index -> IndexedSessionRow? in + guard index < oldValue.endIndex else { return nil } + return IndexedSessionRow(sessionRow: oldValue[index], index: index) + } + + let newSelection = newRowsSet.intersection(previouslySelectedRows) + if let topOfPreviousSelection = previouslySelectedRows.first, newSelection.isEmpty { + // The update has removed the selected row(s). + // e.g. You have the unwatched filter active and then mark the selection as watched + stride(from: topOfPreviousSelection.index, to: -1, by: -1).lazy.compactMap { + IndexedSessionRow(sessionRow: oldValue[$0], index: $0) + }.first { (indexedRow: IndexedSessionRow) -> Bool in + newRowsSet.contains(indexedRow) && indexedRow.sessionRow.isSession + }.flatMap { + newRowsSet.firstIndex(of: $0) + }.map { + newRowsSet[$0].index + }.map { + selectedIndexes = IndexSet(integer: $0) + } + } else { + selectedIndexes = IndexSet(newSelection.map { $0.index }) + } + } + + if selectedIndexes.isEmpty, let defaultIndex = newValue.firstIndex(where: { $0.isSession }) { + selectedIndexes.insert(defaultIndex) + } + + NSAnimationContext.beginGrouping() + let context = NSAnimationContext.current + context.duration = animated ? 0.35 : 0 + + context.completionHandler = { + NSAnimationContext.runAnimationGroup({ context in + context.allowsImplicitAnimation = animated + self.tableView.scrollRowToCenter(selectedIndexes.first ?? 0) + }, completionHandler: nil) + } + + self.tableView.beginUpdates() + + self.tableView.removeRows(at: removedIndexes, withAnimation: [.slideLeft]) + + self.tableView.insertRows(at: addedIndexes, withAnimation: [.slideDown]) + + // insertRows(::) and removeRows(::) will query the delegate for the row count at the beginning + // so we delay updating the data model until after those methods have done their thing + self.displayedRows = newValue + + // This must be after you update the backing model + self.tableView.reloadData(forRowIndexes: needReloadedIndexes, columnIndexes: IndexSet(integersIn: 0..<1)) + + self.tableView.selectRowIndexes(selectedIndexes, byExtendingSelection: false) + + self.log.debug("endUpdates: row count[\(self.displayedRows.count)]") + self.tableView.endUpdates() + NSAnimationContext.endGrouping() + } + } + } + + func isSessionVisible(for session: SessionIdentifiable) -> Bool { + return displayedRows.contains { row -> Bool in + row.represents(session: session) + } + } + + func canDisplay(session: SessionIdentifiable) -> Bool { + return sessionRowProvider.rows?.all.contains { row -> Bool in + row.represents(session: session) + } ?? false + } + + // MARK: - UI + + lazy var tableView: WWDCTableView = { + let v = WWDCTableView() + + // We control the initial selection during initialization + v.allowsEmptySelection = true + + v.focusRingType = .none + v.allowsMultipleSelection = true + v.backgroundColor = .clear + v.headerView = nil + v.rowHeight = Metrics.sessionRowHeight + v.autoresizingMask = [.width, .height] + v.floatsGroupRows = true + v.gridStyleMask = [] + v.style = .fullWidth + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "session")) + v.addTableColumn(column) + + return v + }() + + lazy var scrollView: NSScrollView = { + let v = NSScrollView() + + v.focusRingType = .none + v.drawsBackground = false + v.borderType = .noBorder + v.documentView = self.tableView + v.hasVerticalScroller = true + v.hasHorizontalScroller = false + v.translatesAutoresizingMaskIntoConstraints = false + v.alphaValue = 0 + v.automaticallyAdjustsContentInsets = true + + return v + }() + + // MARK: - Contextual menu + + fileprivate enum ContextualMenuOption: Int { + case watched = 1000 + case unwatched = 1001 + case favorite = 1002 + case removeFavorite = 1003 + case download = 1004 + case cancelDownload = 1005 + case removeDownload = 1006 + case revealInFinder = 1007 + } + + private func setupContextualMenu() { + let contextualMenu = NSMenu(title: "TableView Menu") + + let watchedMenuItem = NSMenuItem(title: "Mark as Watched", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + watchedMenuItem.option = .watched + contextualMenu.addItem(watchedMenuItem) + + let unwatchedMenuItem = NSMenuItem(title: "Mark as Unwatched", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + unwatchedMenuItem.option = .unwatched + contextualMenu.addItem(unwatchedMenuItem) + + contextualMenu.addItem(.separator()) + + let favoriteMenuItem = NSMenuItem(title: "Add to Favorites", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + favoriteMenuItem.option = .favorite + contextualMenu.addItem(favoriteMenuItem) + + let removeFavoriteMenuItem = NSMenuItem(title: "Remove From Favorites", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + removeFavoriteMenuItem.option = .removeFavorite + contextualMenu.addItem(removeFavoriteMenuItem) + + contextualMenu.addItem(.separator()) + + let downloadMenuItem = NSMenuItem(title: "Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + downloadMenuItem.option = .download + contextualMenu.addItem(downloadMenuItem) + + let removeDownloadMenuItem = NSMenuItem(title: "Remove Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + contextualMenu.addItem(removeDownloadMenuItem) + removeDownloadMenuItem.option = .removeDownload + + let cancelDownloadMenuItem = NSMenuItem(title: "Cancel Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + contextualMenu.addItem(cancelDownloadMenuItem) + cancelDownloadMenuItem.option = .cancelDownload + + let revealInFinderMenuItem = NSMenuItem(title: "Reveal in Finder", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + contextualMenu.addItem(revealInFinderMenuItem) + revealInFinderMenuItem.option = .revealInFinder + + tableView.menu = contextualMenu + } + + private func selectedAndClickedRowIndexes() -> IndexSet { + let clickedRow = tableView.clickedRow + let selectedRowIndexes = tableView.selectedRowIndexes + + if clickedRow < 0 || selectedRowIndexes.contains(clickedRow) { + return selectedRowIndexes + } else { + return IndexSet(integer: clickedRow) + } + } + + // swiftlint:disable:next cyclomatic_complexity + @objc private func tableViewMenuItemClicked(_ menuItem: NSMenuItem) { + var viewModels = [SessionViewModel]() + for row in selectedAndClickedRowIndexes() { + guard case .session(let viewModel) = displayedRows[row].kind else { continue } + viewModels.append(viewModel) + } + guard !viewModels.isEmpty else { return } + switch menuItem.option { + case .watched: + delegate?.sessionTableViewContextMenuActionWatch(viewModels: viewModels) + case .unwatched: + delegate?.sessionTableViewContextMenuActionUnWatch(viewModels: viewModels) + case .favorite: + delegate?.sessionTableViewContextMenuActionFavorite(viewModels: viewModels) + case .removeFavorite: + delegate?.sessionTableViewContextMenuActionRemoveFavorite(viewModels: viewModels) + case .download: + delegate?.sessionTableViewContextMenuActionDownload(viewModels: viewModels) + case .cancelDownload: + delegate?.sessionTableViewContextMenuActionCancelDownload(viewModels: viewModels) + case .removeDownload: + delegate?.sessionTableViewContextMenuActionRemoveDownload(viewModels: viewModels) + case .revealInFinder: + delegate?.sessionTableViewContextMenuActionRevealInFinder(viewModels: viewModels) + } + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + for row in selectedAndClickedRowIndexes() { + let sessionRow = displayedRows[row] + + guard case .session(let viewModel) = sessionRow.kind else { break } + + if shouldEnableMenuItem(menuItem: menuItem, viewModel: viewModel) { return true } + } + + return false + } + + private func shouldEnableMenuItem(menuItem: NSMenuItem, viewModel: SessionViewModel) -> Bool { + switch menuItem.option { + case .watched: + let canMarkAsWatched = !viewModel.session.isWatched + && viewModel.session.instances.first?.isCurrentlyLive != true + && viewModel.session.asset(ofType: .streamingVideo) != nil + + return canMarkAsWatched + case .unwatched: + return viewModel.session.isWatched || viewModel.session.progresses.count > 0 + case .favorite: + return !viewModel.isFavorite + case .removeFavorite: + return viewModel.isFavorite + default: () + } + + switch menuItem.option { + case .download: + return MediaDownloadManager.shared.canDownloadMedia(for: viewModel.session) && + !MediaDownloadManager.shared.isDownloadingMedia(for: viewModel.session) && + !MediaDownloadManager.shared.hasDownloadedMedia(for: viewModel.session) + case .removeDownload: + return viewModel.session.isDownloaded + case .cancelDownload: + return MediaDownloadManager.shared.canDownloadMedia(for: viewModel.session) && MediaDownloadManager.shared.isDownloadingMedia(for: viewModel.session) + case .revealInFinder: + return MediaDownloadManager.shared.hasDownloadedMedia(for: viewModel.session) + default: () + } + + return false + } +} + +@available(macOS 26.0, *) +private extension NSMenuItem { + var option: NewSessionsTableViewController.ContextualMenuOption { + get { + guard let value = NewSessionsTableViewController.ContextualMenuOption(rawValue: tag) else { + fatalError("Invalid ContextualMenuOption: \(tag)") + } + + return value + } + set { + tag = newValue.rawValue + } + } +} + +// MARK: - Datasource / Delegate + +private extension NSUserInterfaceItemIdentifier { + static let sessionRow = NSUserInterfaceItemIdentifier(rawValue: "sessionRow") + static let headerRow = NSUserInterfaceItemIdentifier(rawValue: "headerRow") + + static let sessionCell = NSUserInterfaceItemIdentifier(rawValue: "sessionCell") +} + +@available(macOS 26.0, *) +extension NewSessionsTableViewController: NSTableViewDataSource, NSTableViewDelegate { + enum Metrics { + static let headerRowHeight: CGFloat = 32 + static let sessionRowHeight: CGFloat = 64 + } + + func tableViewSelectionDidChange(_ notification: Notification) { + let numberOfRows = tableView.numberOfRows + let selectedRow = tableView.selectedRow + + let row: Int? = (0.. Int { + return displayedRows.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let sessionRow = displayedRows[row] + + switch sessionRow.kind { + case .session(let viewModel): + return cellForSessionViewModel(viewModel) + case .sectionHeader: + return nil + } + } + + private func rowView(with id: NSUserInterfaceItemIdentifier) -> T? where T: NSTableRowView { + var rowView = tableView.makeView(withIdentifier: id, owner: tableView) as? T + if rowView == nil { + rowView = T(frame: .zero) + rowView?.identifier = id + } + return rowView + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + switch displayedRows[row].kind { + case .sectionHeader(let title, let symbol): + let rowView: NewTopicHeaderRow? = rowView(with: .headerRow) + rowView?.title = title + rowView?.symbolName = symbol + return rowView + default: + return rowView(with: .sessionRow) + } + } + + private func cellForSessionViewModel(_ viewModel: SessionViewModel) -> NewSessionTableCellView? { + var cell = tableView.makeView(withIdentifier: .sessionCell, owner: tableView) as? NewSessionTableCellView + + if cell == nil { + cell = NewSessionTableCellView(frame: .zero) + cell?.identifier = .sessionCell + } + + cell?.viewModel = viewModel + + return cell + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + switch displayedRows[row].kind { + case .session: + return Metrics.sessionRowHeight + case .sectionHeader: + return Metrics.headerRowHeight + } + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + switch displayedRows[row].kind { + case .sectionHeader: + return false + case .session: + return true + } + } + + func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { + switch displayedRows[row].kind { + case .sectionHeader: + return true + case .session: + return false + } + } +} + +@available(macOS 26.0, *) +private extension NewSessionsTableViewController { + func prepareForDisplayingFilterItems() { + guard filterItem?.target !== self else { + return + } + for item in [filterItem, searchItem] { + item?.target = self + item?.isHidden = false + } + filterItem?.action = #selector(didTapFilterItem) + filterItem?.menu.removeAllItems() + filterItem?.menu.autoenablesItems = false + filterItem?.menu.addItem(withTitle: "Clear All Filters", action: #selector(didTapClearItem), keyEquivalent: "").target = self + filterItem?.image = NSImage(systemSymbolName: searchHeader.isHidden ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill", accessibilityDescription: searchHeader.isHidden ? "Show Filter Options" : "Hide Filter Options") + filterItem?.toolTip = filterItem?.image?.accessibilityDescription + + let currentTextFilter = searchCoordinator.effectiveFilters.first(where: { $0.identifier == .text }) as? TextualFilter + searchItem?.searchField.stringValue = currentTextFilter?.value ?? "" + searchItem?.searchField.delegate = self // set delegate after restoring state + searchItem?.searchField.suggestionsDelegate = self + } + + func prepareForHidingFilterItems() { + for item in [filterItem, searchItem] { + item?.target = nil + item?.isHidden = true + } + searchItem?.searchField.delegate = nil + searchItem?.searchField.suggestionsDelegate = nil + } + + @objc private func didTapFilterItem(_ item: NSToolbarItem) { + let isHeaderHiddenNext = !searchHeader.isHidden + setFilterView(isHidden: isHeaderHiddenNext, item: item) + } + + private func setFilterView(isHidden isHeaderHiddenNext: Bool, item: NSToolbarItem) { + let nextTopInset = isHeaderHiddenNext ? 0 : searchHeader.bounds.height + item.image = NSImage(systemSymbolName: isHeaderHiddenNext ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill", accessibilityDescription: isHeaderHiddenNext ? "Show Filter Options" : "Hide Filter Options") + item.toolTip = item.image?.accessibilityDescription + NSAnimationContext.runAnimationGroup { _ in + searchHeader.animator().alphaValue = isHeaderHiddenNext ? 0 : 1 + scrollTopConstraint.animator().constant = nextTopInset + } completionHandler: { + self.searchHeader.isHidden = isHeaderHiddenNext + } + } + + @objc private func didTapClearItem(_ item: Any) { + searchCoordinator.resetAction.send() + if let item = filterItem, let searchField = searchItem?.searchField { + searchField.stringValue = "" + updateTextFilter(sender: searchField) + setFilterView(isHidden: true, item: item) + } + view.window?.makeFirstResponder(tableView) + } +} + +@available(macOS 26.0, *) +extension NewSessionsTableViewController: NSSearchFieldDelegate { + func controlTextDidChange(_ obj: Notification) { + guard let sender = obj.object as? NSSearchField else { + return + } + updateTextFilter(sender: sender) + } + + private func updateTextFilter(sender: NSTextField) { + let filters = searchCoordinator.effectiveFilters + guard + let textIdx = filters.firstIndex(where: { $0.identifier == .text }), + var currentFilter = filters[textIdx] as? TextualFilter, + currentFilter.value != sender.stringValue + else { + return + } + currentFilter.value = sender.stringValue + searchCoordinator.effectiveFilters[textIdx] = currentFilter + searchCoordinator.updatePredicate(.userInput) + } +} + +@available(macOS 26.0, *) +extension NewSessionsTableViewController: NSTextSuggestionsDelegate { + typealias SuggestionItemType = GlobalSearchCoordinator.SearchTarget + + func textField(_ textField: NSTextField, provideUpdatedSuggestions responseHandler: @escaping (ItemResponse) -> Void) { + var items = searchCoordinator.availableSearchTargets.map { target in + var item = NSSuggestionItem(representedValue: target, title: target.rawValue) + item.image = searchCoordinator.searchTarget == target ? NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) : nil + return item + } + if items.count == 1 { + items = [] + } + let section = NSSuggestionItemSection(title: "Search in", items: items) + var response = ItemResponse(itemSections: [section]) + response.phase = .final + responseHandler(response) + } + + func textField(_ textField: NSTextField, textCompletionFor item: Item) -> String? { + nil + } + + func textField(_ textField: NSTextField, didSelect item: Item) { + var filters = searchCoordinator.effectiveFilters + for idx in filters.indices { + filters[idx].reset() + } + searchCoordinator.effectiveFilters = filters + searchCoordinator.updatePredicate(.configurationChange) + searchCoordinator.searchTarget = item.representedValue + updateTextFilter(sender: textField) + } +} diff --git a/WWDC/Tahoe/ReplaceableSplitViewController.swift b/WWDC/Tahoe/ReplaceableSplitViewController.swift new file mode 100644 index 00000000..5a998239 --- /dev/null +++ b/WWDC/Tahoe/ReplaceableSplitViewController.swift @@ -0,0 +1,194 @@ +// +// ReplaceableSplitViewController.swift +// WWDC +// +// Created by luca on 30.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import Combine +import SwiftUI + +@available(macOS 26.0, *) +class ReplaceableSplitViewController: NSSplitViewController, WWDCTabController { + typealias Tab = MainWindowTab + let exploreViewModel: NewExploreViewModel + var scheduleViewModel: SessionListViewModel! + let scheduleTable: NewSessionsTableViewController + let videosTable: NewSessionsTableViewController + let detailViewModel: SessionItemViewModel + @Published var activeTab: Tab = .explore { + didSet { + guard activeTab != oldValue else { + return + } + changeContent() + NSAnimationContext.runAnimationGroup { _ in + topSegmentControl?.animator().selectedSegment = activeTab.rawValue + } + } + } + + var activeTabPublisher: AnyPublisher { + $activeTab.eraseToAnyPublisher() + } + + fileprivate var sidebarItem: NSSplitViewItem! + fileprivate var detailItem: NSSplitViewItem! + + private weak var windowController: NewMainWindowController? + + init(windowController: WWDCWindowControllerObject, exploreViewModel: NewExploreViewModel, scheduleTable: NewSessionsTableViewController, videosTable: NewSessionsTableViewController, detailViewModel: SessionItemViewModel) { + self.windowController = windowController as? NewMainWindowController + self.exploreViewModel = exploreViewModel + self.scheduleTable = scheduleTable + self.videosTable = videosTable + self.detailViewModel = detailViewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var loadingView: ModalLoadingView? + + func showLoading() { + loadingView = ModalLoadingView.show(attachedTo: view) + } + + func hideLoading() { + loadingView?.hide() + loadingView = nil + if windowController?.window?.toolbar == nil { + windowController?.setupToolbar(tab: activeTab) + } + } + + override func viewDidLoad() { + super.viewDidLoad() + sidebarItem = NSSplitViewItem(sidebarWithViewController: SplitContainer(nibName: nil, bundle: nil)) + sidebarItem.container.isSidebar = true + sidebarItem.canCollapse = true + sidebarItem.automaticallyAdjustsSafeAreaInsets = true + addSplitViewItem(sidebarItem) + detailItem = NSSplitViewItem(viewController: SplitContainer(nibName: nil, bundle: nil)) + detailItem.automaticallyAdjustsSafeAreaInsets = true + addSplitViewItem(detailItem) + sidebarItem.viewController.view.setContentHuggingPriority(.defaultHigh, for: .horizontal) + detailItem.viewController.view.setContentHuggingPriority(.defaultLow, for: .horizontal) + detailItem.viewController.view.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + showLoading() + } + + private func changeContent() { + guard sidebarItem != nil else { + return + } + let sidebarContent: NSView = { + switch activeTab { + case .explore: return NSHostingView(rootView: NewExploreCategoryList().environment(exploreViewModel)) + case .schedule: return scheduleTable.view + case .videos: return videosTable.view + } + }() + let sidebarContainer = sidebarItem.container + Task { + await sidebarContainer.replaceContent(sidebarContent) + } + + let detailContent: NSView = { + switch activeTab { + case .explore: return NSHostingView(rootView: NewExploreTabDetailView() + .environment(exploreViewModel) + .onAppear { [weak self] in + self?.hideLoading() + } + ) + case .schedule, .videos: + let searchCoordinator = activeTab == .schedule ? scheduleTable.searchCoordinator : videosTable.searchCoordinator + return NSHostingView(rootView: NewSessionDetailView() + .environment(detailViewModel) + .environment(searchCoordinator) + .onAppear { [weak self] in + self?.hideLoading() + } + ) + } + }() + let detailContainer = detailItem.container + Task { + await detailContainer.replaceContent(detailContent) + } + } +} + +private class SplitContainer: NSViewController, Sendable { + var isSidebar = false + override func loadView() { + super.loadView() + guard isSidebar else { + return + } + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: Constants.sidebarWidth), + view.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.minimumWindowHeight) + ]) + } + + func replaceContent(_ content: NSView) async { + await NSAnimationContext.runAnimationGroup { _ in + view.animator().alphaValue = 0 + } + view.subviews.forEach { $0.removeFromSuperview() } + view.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: view.topAnchor), + content.leadingAnchor.constraint(equalTo: view.leadingAnchor), + content.bottomAnchor.constraint(equalTo: view.bottomAnchor), + content.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + await NSAnimationContext.runAnimationGroup { _ in + view.animator().alphaValue = 1 + } + } +} + +private extension NSSplitViewItem { + var container: SplitContainer { + // swiftlint:disable:next force_cast + viewController as! SplitContainer + } +} + +@available(macOS 26.0, *) +class SplitViewItemAccessoryView: NSSplitViewItemAccessoryViewController { + let content: Content + init(@ViewBuilder content: () -> Content) { + self.content = content() + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + let label = NSHostingView(rootView: content) + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: view.topAnchor), + label.leadingAnchor.constraint(equalTo: view.leadingAnchor), + label.bottomAnchor.constraint(equalTo: view.bottomAnchor), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + automaticallyAppliesContentInsets = false + } +} diff --git a/WWDC/Tahoe/SessionDetail/Components/DetailDescriptionView.swift b/WWDC/Tahoe/SessionDetail/Components/DetailDescriptionView.swift new file mode 100644 index 00000000..8cc6c402 --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/Components/DetailDescriptionView.swift @@ -0,0 +1,243 @@ +// +// DetailDescriptionView.swift +// WWDC +// +// Created by luca on 05.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +@available(macOS 26.0, *) +extension NewSessionDetailView { + struct SessionDescriptionView: View { + @Environment(SessionItemViewModel.self) var viewModel + @Binding var tab: SessionDetailsViewModel.SessionTab + let scrollPosition: Binding + + var body: some View { + Group { + switch tab { + case .overview: + Group { + OverviewContentView() + RelatedSessionsView(sessions: viewModel.relatedSessions, scrollPosition: scrollPosition) + } + .transition(.blurReplace) + case .transcript: + if let session = viewModel.session { + NewTranscriptView(viewModel: session, scrollPosition: scrollPosition) + .transition(.blurReplace) + } + case .bookmarks: + Text("Bookmarks view coming soon") + .foregroundColor(.secondary) + } + } + .animation(.bouncy, value: tab) + } + } +} + +@available(macOS 26.0, *) +private struct OverviewContentView: View { + @Environment(SessionItemViewModel.self) var viewModel + + @Environment(\.coordinator) private var appCoordinator + var body: some View { + VStack(alignment: .leading, spacing: 24) { + HStack { + Text(viewModel.title) + .font(.init(NSFont.boldTitleFont)) + .foregroundStyle(.primary) + .kerning(-0.5) + .textSelection(.enabled) + .transition(.blurReplace) + Spacer() + NewSessionActionsView() + } + Text(viewModel.summary) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .lineHeight(.multiple(factor: 1.2)) + .textSelection(.enabled) + .transition(.blurReplace) + Text(viewModel.footer) + .font(.system(size: 16)) + .foregroundStyle(.tertiary) + .allowsTightening(true) + .truncationMode(.tail) + .textSelection(.enabled) + .transition(.blurReplace) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.bottom, .horizontal]) + .padding(.top, 8) // incase tabs are hidden + .animation(.bouncy, value: viewModel.title) + .animation(.bouncy, value: viewModel.summary) + .animation(.bouncy, value: viewModel.footer) + } +} + +@available(macOS 26.0, *) +private struct NewSessionActionsView: View { + @Environment(SessionItemViewModel.self) var viewModel + + var body: some View { + HStack(spacing: 22) { + if !viewModel.slidesButtonIsHidden { + Button { + viewModel.showSlides() + } label: { + Image(systemName: "play.rectangle.on.rectangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + } + .buttonStyle(SymbolButtonStyle()) + .help("Open slides") + .transition(.scale.combined(with: .opacity)) + } + + Button { + viewModel.toggleFavorite() + } label: { + Image(systemName: viewModel.isFavorite ? "star.fill" : "star") + .resizable() + .contentTransition(.symbolEffect(.replace.magic(fallback: .downUp.byLayer), options: .nonRepeating)) + .aspectRatio(contentMode: .fit) + } + .buttonStyle(SymbolButtonStyle()) + .help(viewModel.isFavorite ? "Remove from favorites" : "Add to favorites") + .transition(.scale.combined(with: .opacity)) + + downloadButton + + if viewModel.downloadState == .downloaded, viewModel.isPlaying { + Button { + viewModel.shareClip() + } label: { + Image(systemName: "scissors") + .resizable() + .aspectRatio(contentMode: .fit) + } + .buttonStyle(SymbolButtonStyle()) + .help("Share a Clip") + .transition(.scale.combined(with: .opacity)) + } + + if !viewModel.calendarButtonIsHidden { + Button { + viewModel.addCalendar() + } label: { + Image(systemName: "calendar.badge.plus") + .resizable() + .aspectRatio(contentMode: .fit) + } + .buttonStyle(SymbolButtonStyle()) + .help("Add to Calendar") + .transition(.scale.combined(with: .opacity)) + } + + if let shareLink { + ShareLink(items: [shareLink]) { + Image(systemName: "square.and.arrow.up") + .resizable() + .aspectRatio(contentMode: .fit) + } + .buttonStyle(SymbolButtonStyle()) + .help("Share session") + } + } + .animation(.bouncy, value: viewModel.slidesButtonIsHidden) + .animation(.bouncy, value: viewModel.calendarButtonIsHidden) + .animation(.bouncy, value: viewModel.downloadState) + .animation(.bouncy, value: viewModel.isPlaying) + .frame(height: 24) + } + + /// States managed by DownloadState enum: + /// - .notDownloadable: no button, no progress (allocatesSpace: false) + /// - .downloadable: shows download button (showsButton: true, help: "Download video for offline watching") + /// - .pending: shows progress indicator without percentage (showsButton: false, help: "Preparing download") + /// - .downloading(progress): shows progress indicator with percentage (showsButton: false, help: "Downloading: X%") + /// - .downloaded: shows delete button (showsButton: true, help: "Delete downloaded video") + @ViewBuilder var downloadButton: some View { + if viewModel.downloadState.showsInlineButton { + Button { + switch viewModel.downloadState { + case .notDownloadable: + break + case .downloadable: + viewModel.download() + case .pending, .downloading: + viewModel.cancelDownload() + case .downloaded: + viewModel.deleteDownload() + } + } label: { + switch viewModel.downloadState { + case .notDownloadable, .downloadable: + Image(systemName: "arrow.down.document.fill").resizable() + .aspectRatio(contentMode: .fit) + case .pending, .downloading: + let value = min(1.0, viewModel.downloadState.downloadProgress ?? 0) + Image(systemName: "xmark.circle", variableValue: value) + .resizable() + .symbolVariableValueMode(.draw) + .aspectRatio(contentMode: .fit) + // magical replace will crash somehow + case .downloaded: + Image(systemName: "trash").resizable() + .aspectRatio(contentMode: .fit) + } + } + .buttonStyle(SymbolButtonStyle()) + .help(downloadButtonHelp) + } + } + + var downloadButtonHelp: String { + switch viewModel.downloadState { + case .downloaded: + "Delete downloaded video" + case .downloadable: + "Download video for offline watching" + case .downloading(let progress): + "Downloading: \(progress.formatted(.percent.precision(.fractionLength(0))))" + case .pending: + "Preparing download" + case .notDownloadable: + "" + } + } + + var shareLink: URL? { + guard + let webpageAsset = viewModel.session?.session.asset(ofType: .webpage), + let url = URL(string: webpageAsset.remoteURL) + else { return nil } + + return url.replacingAppleDeveloperHostWithNativeHost + } +} + +@available(macOS 26.0, *) +private struct SymbolButtonStyle: ButtonStyle { + @State private var isHovered = false + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.title) + .foregroundStyle(Color.accentColor) + .scaleEffect(configuration.isPressed ? 0.9 : 1) // scale content only + .scaleEffect(isHovered ? 1.2 : 1) + .animation(.bouncy(extraBounce: 0.3), value: configuration.isPressed) + .animation(.smooth, value: isHovered) + .contentShape(.rect) + .onHover { isHovering in + withAnimation { + isHovered = isHovering + } + } + } +} diff --git a/WWDC/Tahoe/SessionDetail/Components/DetailRelatedSessionView.swift b/WWDC/Tahoe/SessionDetail/Components/DetailRelatedSessionView.swift new file mode 100644 index 00000000..046fc324 --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/Components/DetailRelatedSessionView.swift @@ -0,0 +1,59 @@ +// +// DetailRelatedSessionView.swift +// WWDC +// +// Created by luca on 05.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +@available(macOS 26.0, *) +extension NewSessionDetailView { + struct RelatedSessionsView: View { + let sessions: [SessionViewModel] + @Environment(\.coordinator) private var coordinator + let scrollPosition: Binding + + enum Metrics { + static let height: CGFloat = 96 + scrollerOffset + static let itemHeight: CGFloat = 64 + static let scrollerOffset: CGFloat = 15 + static let scrollViewHeight: CGFloat = itemHeight + scrollerOffset + static let itemWidth: CGFloat = 360 + static let itemSpacing: CGFloat = 10 + } + + let columns = [ + GridItem(.flexible(minimum: Metrics.itemWidth, maximum: .infinity), spacing: Metrics.itemSpacing), + GridItem(.flexible(minimum: Metrics.itemWidth, maximum: .infinity), spacing: Metrics.itemSpacing) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text("Related Sessions") + .font(Font(NSFont.wwdcRoundedSystemFont(ofSize: 20, weight: .semibold) as CTFont)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal) + + LazyVGrid(columns: columns) { + ForEach(sessions, id: \.identifier) { session in + Button { + scrollPosition.wrappedValue.scrollTo(edge: .top) + coordinator?.selectSessionOnAppropriateTab(with: session) + } label: { + SessionItemViewForDetail(session: session) + } + .buttonStyle(SessionItemButtonStyle(style: .rounded)) + .id(session.identifier) + } + } + .padding([.bottom, .horizontal]) + } + .opacity(sessions.isEmpty ? 0 : 1) + } + } +} diff --git a/WWDC/Tahoe/SessionDetail/Components/SessionCoverView.swift b/WWDC/Tahoe/SessionDetail/Components/SessionCoverView.swift new file mode 100644 index 00000000..72bb5e82 --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/Components/SessionCoverView.swift @@ -0,0 +1,109 @@ +// +// SessionCoverView.swift +// WWDC +// +// Created by luca on 05.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +struct SessionCoverView: View { + var coverImageURL: URL? + var isThumbnail: Bool = false + @ViewBuilder let decoration: (_ image: Image, _ isPlaceHolder: Bool) -> Content + let image: State + @State private var isPlaceholder = true + private let operation = State(initialValue: .init()) + + init(coverImageURL: URL? = nil, placeholder: NSImage? = nil, isThumbnail: Bool = false, @ViewBuilder decoration: @escaping (_ image: Image, _ isPlaceHolder: Bool) -> Content) { + self.coverImageURL = coverImageURL + self.isThumbnail = isThumbnail + self.decoration = decoration + self.image = .init(initialValue: placeholder ?? .noimage) + } + + var body: some View { + decoration(Image(nsImage: image.wrappedValue), isPlaceholder) + .task(id: coverImageURL) { + if isThumbnail { + await downloadCover(height: 50) + } else { + await downloadCover() + } + } + } + + @MainActor + private func updateImage(_ img: NSImage?) { + image.wrappedValue = img ?? .noimage + isPlaceholder = img == nil + } +} + +private extension SessionCoverView { + @ImageDownloadActor + private func downloadCover(height: CGFloat? = nil) async { + await updateImage(nil) + guard let url = coverImageURL else { + return + } + let thumbnailOnly = (height ?? 999) <= Constants.thumbnailHeight + let cached = ImageDownloadCenter.shared.cachedImage(from: url, thumbnailOnly: thumbnailOnly) + if let cached { + await updateImage(cached) + return + } + guard !Task.isCancelled else { + return + } + await operation.wrappedValue.cancel() + let img = await operation.wrappedValue.download(from: url, thumbnailHeight: height, thumbnailOnly: thumbnailOnly) + guard !Task.isCancelled else { + return + } + await updateImage(img) + } +} + +/// isolate ImageDownloadCenter caching to this actor +@globalActor +private actor ImageDownloadActor { + static let shared = ImageDownloadActor() +} + +private class AsyncImageOperation { + private weak var operation: Operation? // ImageDownloadCenter owns + + deinit { + operation?.cancel() +// print("AsyncImageOperation deinit") + } + + func cancel() { + operation?.cancel() + operation = nil + } + + func download(from url: URL, thumbnailHeight: CGFloat?, thumbnailOnly: Bool = false) async -> NSImage? { + guard !Task.isCancelled else { + return nil + } + + return await withCheckedContinuation { continuation in + var oneTimeContinuation: CheckedContinuation? = continuation + operation?.cancel() + operation = ImageDownloadCenter.shared.downloadImage(from: url, thumbnailHeight: thumbnailHeight, thumbnailOnly: thumbnailOnly) { _, result in + defer { + oneTimeContinuation = nil + } + guard let img = thumbnailOnly ? (result.thumbnail ?? result.original) : result.original else { + oneTimeContinuation?.resume(returning: nil) + return + } + oneTimeContinuation?.resume(returning: img) + } + } + } +} diff --git a/WWDC/Tahoe/SessionDetail/Components/SessionPlayerView.swift b/WWDC/Tahoe/SessionDetail/Components/SessionPlayerView.swift new file mode 100644 index 00000000..13b3e653 --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/Components/SessionPlayerView.swift @@ -0,0 +1,99 @@ +// +// SessionPlayerView.swift +// WWDC +// +// Created by luca on 10.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import SwiftUI + +@available(macOS 26.0, *) +struct SessionPlayerView: View { + @Environment(SessionItemViewModel.self) private var viewModel + @Environment(\.coordinator) private var coordinator + @State private var coverRatio: CGFloat? + @State private var isLoadingThumbnail = true + + var body: some View { + Group { + if + viewModel.isPlaying, + let controller = coordinator?.currentShelfViewController, + controller.viewModel?.identifier == viewModel.session?.identifier // check again when reusing + { + GeometryReader { proxy in + ViewControllerWrapper(viewController: controller, additionalSafeAreaInsets: proxy.safeAreaInsets) + .ignoresSafeArea() + } + .transition(.blurReplace) + } else { + cover + .opacity(viewModel.isMediaAvailable ? 1 : 0) + .transition(.blurReplace) + } + } + .aspectRatio(coverRatio, contentMode: .fit) + .task(id: viewModel.session?.identifier) { [weak coordinator, weak viewModel] in + viewModel?.isPlaying = coordinator?.currentShelfViewController?.viewModel?.identifier == viewModel?.session?.identifier + } + } + + @ViewBuilder + private var cover: some View { + SessionCoverView(coverImageURL: viewModel.coverImageURL) { image, isPlaceholder in + image.resizable() + .aspectRatio(contentMode: .fit) + .extendBackground() + .transition(.blurReplace) + .animation(.smooth, value: isPlaceholder) + .task(id: isPlaceholder) { + isLoadingThumbnail = isPlaceholder + } + } + .onGeometryChange(for: CGSize.self, of: { proxy in + proxy.size + }, action: { newValue in + if newValue.height > 0 { + coverRatio = newValue.width / newValue.height + } + }) + .overlay(alignment: .center) { + if viewModel.isMediaAvailable { + playButton + .transition(.scale.combined(with: .opacity)) + } + } + } + + @ViewBuilder + private var playButton: some View { + Button { + guard let session = viewModel.session else { + return + } + defer { + viewModel.isPlaying = true + } + if let existing = coordinator?.currentShelfViewController { + existing.viewModel = session + existing.playButton.isHidden = true + existing.play(nil) + return + } + let viewController = ShelfViewController() + viewController.viewModel = session + viewController.delegate = coordinator + viewController.playButton.isHidden = true + coordinator?.currentShelfViewController = viewController + viewController.play(nil) + } label: { + Label("Play", systemImage: "play.fill") + } + .controlSize(.extraLarge) + .buttonBorderShape(.capsule) + .buttonStyle(.glass) + .hoverEffect(scale: 1.1) + .disabled(isLoadingThumbnail) + } +} diff --git a/WWDC/Tahoe/SessionDetail/NewSessionDetailView.swift b/WWDC/Tahoe/SessionDetail/NewSessionDetailView.swift new file mode 100644 index 00000000..2b5bfd1a --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/NewSessionDetailView.swift @@ -0,0 +1,89 @@ +// +// NewSessionDetailView.swift +// WWDC +// +// Created by luca on 05.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +@available(macOS 26.0, *) +struct NewSessionDetailView: View { + @Environment(SessionItemViewModel.self) var viewModel + @Environment(GlobalSearchCoordinator.self) var searchCoordinator + @State private var availableTabs: [SessionDetailsViewModel.SessionTab] = [.overview] + @State private var tab: SessionDetailsViewModel.SessionTab = .overview + @State private var scrollPosition = ScrollPosition() + var body: some View { + ScrollView { + SessionDescriptionView(tab: $tab, scrollPosition: $scrollPosition) + } + .scrollPosition($scrollPosition, anchor: .center) + .safeAreaBar(edge: .top) { + VStack(alignment: .leading, spacing: 0) { + SessionPlayerView() + if availableTabs.count > 1 { + tabBar + } + } + } + .ignoresSafeArea(edges: .top) + .scrollEdgeEffectStyle(.soft, for: .vertical) + .task(id: viewModel.isTranscriptAvailable) { + let newValue = viewModel.isTranscriptAvailable + if newValue, !availableTabs.contains(.transcript) { + availableTabs.append(.transcript) + searchCoordinator.availableSearchTargets = [.sessions, .transcripts] + } else if !newValue { + availableTabs.removeAll(where: { $0 == .transcript }) + searchCoordinator.availableSearchTargets = [.sessions] + if tab == .transcript { + tab = availableTabs.first ?? .overview + } + } + } + .onChange(of: tab) { oldValue, newValue in + if newValue == .transcript { + searchCoordinator.searchTarget = .transcripts + } else { + searchCoordinator.searchTarget = .sessions + } + } + .onChange(of: searchCoordinator.searchTarget) { oldValue, newValue in + if newValue == .transcripts, tab != .transcript, availableTabs.contains(.transcript) { + withAnimation { + tab = .transcript + } + } + } + } + + @ViewBuilder + private var tabBar: some View { + Picker("Tabs", selection: $tab) { + ForEach(availableTabs, id: \.self) { t in + Text(t.title) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.large) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } +} + +private extension SessionDetailsViewModel.SessionTab { + var title: String { + switch self { + case .overview: + return "Overview" + case .transcript: + return "Transcript" + case .bookmarks: + return "Bookmarks" + } + } +} diff --git a/WWDC/Tahoe/SessionDetail/NewTranscriptView.swift b/WWDC/Tahoe/SessionDetail/NewTranscriptView.swift new file mode 100644 index 00000000..60bbd6d3 --- /dev/null +++ b/WWDC/Tahoe/SessionDetail/NewTranscriptView.swift @@ -0,0 +1,248 @@ +// +// NewTranscriptView.swift +// WWDC +// +// Created by luca on 03.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import ConfCore +import SwiftUI + +@available(macOS 26.0, *) +struct NewTranscriptView: View { + @SwiftUI.Environment(GlobalSearchCoordinator.self) var searchCoordinator + @State private var lines: [TranscriptLine] = [] + private let originalLines = State<[TranscriptLine]>(initialValue: []) + @State private var selectedLine: TranscriptLine? + let viewModel: SessionViewModel + @Binding var scrollPosition: ScrollPosition + + @State private var maskHeight: CGFloat? + @State private var readyToPlay: Bool = false + var body: some View { + LazyVStack(alignment: .leading, spacing: 5) { + ForEach(lines) { line in + Button("") { + seekVideoTo(line: line) + } + .buttonStyle(LineButtonStyle(line: line, selectedLine: selectedLine)) + .id(line) + .scrollTransition { content, phase in + content + .opacity(phase.isIdentity ? 1 : 0.7) + .blur(radius: phase.isIdentity ? 0 : 0.5) + } + } + .scrollTargetLayout() + } + .padding() + .opacity(readyToPlay ? 1 : 0.5) + .disabled(!readyToPlay) + .transition(.blurReplace) + .onReceive(linesUpdate) { newValue in + let filtered = newValue.filter { !$0.body.isEmpty } + guard filtered != originalLines.wrappedValue else { + return + } + originalLines.wrappedValue = filtered + withAnimation { + lines = filtered + } + updateCurrentLineIfNeeded() + } + .onChange(of: searchCoordinator.searchTermForTranscript, { oldValue, newValue in + guard let newValue, !newValue.isEmpty else { + lines = originalLines.wrappedValue + updateCurrentLineIfNeeded() + return + } + lines = originalLines.wrappedValue.filter({ $0.body.lowercased().contains(newValue.lowercased()) }) + updateCurrentLineIfNeeded() + }) + .onReceive(highlightChange) { newValue in + guard newValue != selectedLine else { + return + } + guard searchCoordinator.searchTermForTranscript == nil else { + return + } + withAnimation(.bouncy) { + selectedLine = newValue + scrollPosition.scrollTo(id: newValue, anchor: .center) + } + } + .task { + updateCurrentLineIfNeeded() + } + } + + private var sessionID: String { + viewModel.sessionIdentifier + } + + private var highlightChange: AnyPublisher { + NotificationCenter.default.publisher(for: .HighlightTranscriptAtCurrentTimecode) + .filter { ($0.userInfo?["session_id"] as? String) == sessionID } + .compactMap { note in + guard let timecode = note.object as? NSString else { return nil } + + guard let annotation = lines.findNearestLine(to: timecode.doubleValue, flipLastToFirst: false) else { + return nil + } + return annotation + } + .eraseToAnyPublisher() + } + + private var linesUpdate: AnyPublisher<[TranscriptLine], Never> { + viewModel.rxTranscriptAnnotations + .replaceErrorWithEmpty() + .map { list in + list.map { TranscriptLine(timecode: $0.timecode, body: $0.body) } + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + private func seekVideoTo(line: TranscriptLine) { + withAnimation(.bouncy) { + selectedLine = line + scrollPosition.scrollTo(id: line, anchor: .center) + } + guard let transcript = viewModel.session.transcript() else { return } + + let annotation = TranscriptAnnotation() + annotation.body = line.body + annotation.timecode = line.timecode + + let notificationObject = (transcript, annotation) + + NotificationCenter.default.post(name: NSNotification.Name.TranscriptControllerDidSelectAnnotation, object: notificationObject) + } + + private func updateCurrentLineIfNeeded() { + let currentPosition = viewModel.session.progresses.first?.currentPosition ?? 0 + guard + let line = lines.findNearestLine(to: currentPosition) + else { + return + } + withAnimation { + selectedLine = line + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // wait until content fully appears + scrollPosition.scrollTo(id: line, anchor: .center) + } + withAnimation(.bouncy.delay(0.3)) { + readyToPlay = true + } + } +} + +private extension GlobalSearchCoordinator { + var searchTermForTranscript: String? { + guard searchTarget == .transcripts else { + return nil + } + return (effectiveFilters.first(where: { $0.identifier == .text }) as? TextualFilter)?.value + } +} + +struct TranscriptLine: Identifiable, Hashable { + var id: String { + "\(timecode)-\(body)" + } + + /// The time this annotation occurs within the video + let timecode: Double + /// The annotation's text + let body: String +} + +private struct LineButtonStyle: ButtonStyle { + let videoTimeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = [.pad] + return formatter + }() + + @State private var isHovered = false + let line: TranscriptLine + let selectedLine: TranscriptLine? + func makeBody(configuration: ButtonStyleConfiguration) -> some View { + Text(line.body) + .font(.title) + .fontWeight(.medium) + .lineLimit(nil) + .transition(.blurReplace) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .clipShape(RoundedRectangle(cornerRadius: 5)) // clip first + .scaleEffect(configuration.isPressed ? 0.98 : 1, anchor: .leading) + .scaleEffect(selectedLine == line ? 1.02 : 1, anchor: .leading) + .overlay(alignment: .trailing, content: { + Text(videoTimeFormatter.string(from: line.timecode) ?? "") + .font(.title2) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + .transition(.blurReplace.combined(with: .scale)) + .padding() + .shadow(radius: 5) + .blurBackground(opacity: 0.9) + .opacity(isHovered ? 1 : 0) + }) + .foregroundStyle(selectedLine == line ? .primary : .secondary) + .animation(.bouncy, value: configuration.isPressed) + .animation(.bouncy, value: isHovered) + .contentShape(Rectangle()) // make the whole line hoverable + .onHover { isHovering in + isHovered = isHovering + } + } +} + +private extension Array where Element == TranscriptLine { + /// Assumes lines are sorted by timecode. + /// + /// - Parameters: + /// - timecode: The target timecode in seconds to search for. + /// - flipLastToFirst: If true, when playback has reached the end and restarts, the first line will be considered the closest for a smoother replay experience. + /// - Returns: The transcript line closest to the given timecode, or `nil` if the array is empty. + func findNearestLine(to timecode: Double, flipLastToFirst: Bool = true) -> TranscriptLine? { + guard !isEmpty else { return nil } + var low = 0 + var high = count - 1 + + while low <= high { + let mid = (low + high) / 2 + if self[mid].timecode == timecode { + return self[mid] + } else if self[mid].timecode < timecode { + low = mid + 1 + } else { + high = mid - 1 + } + } + + // Now low is the index of the smallest number >= target + // high is the largest number < target + let lowDiff = (low < count) ? abs(self[low].timecode - timecode) : .greatestFiniteMagnitude + let highDiff = (high >= 0) ? abs(self[high].timecode - timecode) : .greatestFiniteMagnitude + + if lowDiff < highDiff { + return self[low] + } else { + if high == count - 1 && flipLastToFirst { + return self[0] + } else { + return self[high] + } + } + } +} diff --git a/WWDC/Tahoe/SessionList/NewSessionTableCellView.swift b/WWDC/Tahoe/SessionList/NewSessionTableCellView.swift new file mode 100644 index 00000000..c31d6bd4 --- /dev/null +++ b/WWDC/Tahoe/SessionList/NewSessionTableCellView.swift @@ -0,0 +1,43 @@ +// +// NewSessionTableCellView.swift +// WWDC +// +// Created by luca on 09.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import SwiftUI + +final class NewSessionTableCellView: NSTableCellView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + setup() + } + + var viewModel: SessionViewModel? { + get { + return cellViewModel.session + } + set { + cellViewModel.session = newValue + } + } + + @available(*, unavailable) + required init?(coder decoder: NSCoder) { + fatalError() + } + + private lazy var cellViewModel = SessionItemViewModel() + + private lazy var hostingView: NSView = { + return NSHostingView(rootView: SessionItemViewForSidebar().environment(cellViewModel)) + }() + + private func setup() { + hostingView.autoresizingMask = [.width, .height] + addSubview(hostingView) + } +} diff --git a/WWDC/Tahoe/SessionList/NewTopicHeaderRow.swift b/WWDC/Tahoe/SessionList/NewTopicHeaderRow.swift new file mode 100644 index 00000000..7a4c24dc --- /dev/null +++ b/WWDC/Tahoe/SessionList/NewTopicHeaderRow.swift @@ -0,0 +1,96 @@ +// +// TopicHeaderRow.swift +// WWDC +// +// Created by luca on 02.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Cocoa +import SwiftUI + +@available(macOS 15.0, *) +final class NewTopicHeaderRow: NSTableRowView { + private lazy var viewModel = HeaderRowViewModel(title: "") + + var title: String { + get { + viewModel.title + } + set { + viewModel.title = newValue + } + } + + var symbolName: String? { + get { + viewModel.symbolName + } + set { + viewModel.symbolName = newValue + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + update() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + override func drawSelection(in dirtyRect: NSRect) {} + + override func drawBackground(in dirtyRect: NSRect) {} + + private func update() { + let v = NSHostingView(rootView: NewTopicHeaderRowContent().environment(viewModel)) + v.translatesAutoresizingMaskIntoConstraints = false + + addSubview(v) + + NSLayoutConstraint.activate([ + v.leadingAnchor.constraint(equalTo: leadingAnchor), + v.trailingAnchor.constraint(equalTo: trailingAnchor), + v.topAnchor.constraint(equalTo: topAnchor), + v.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} + +@Observable +private class HeaderRowViewModel { + var title: String + var symbolName: String? + + init(title: String, symbolName: String? = nil) { + self.title = title + self.symbolName = symbolName + } +} + +@available(macOS 15.0, *) +private struct NewTopicHeaderRowContent: View { + @Environment(HeaderRowViewModel.self) private var viewModel + var body: some View { + HStack { + if let symbolName = viewModel.symbolName { + Image(systemName: symbolName) + .symbolVariant(.fill) + .contentTransition(.symbolEffect(.replace.magic(fallback: .offUp.wholeSymbol), options: .nonRepeating)) + .transition(.blurReplace) + } + + Text(viewModel.title) + .font(.headline) + .transition(.blurReplace) + } + .foregroundStyle(.secondary) + .padding(.horizontal) + .animation(.smooth, value: viewModel.title) + .animation(.smooth, value: viewModel.symbolName) + .frame(maxWidth: .infinity, minHeight: SessionsTableViewController.Metrics.headerRowHeight, maxHeight: SessionsTableViewController.Metrics.headerRowHeight, alignment: .leading) + } +} diff --git a/WWDC/Tahoe/SessionList/SessionItemView.swift b/WWDC/Tahoe/SessionList/SessionItemView.swift new file mode 100644 index 00000000..c429a7cf --- /dev/null +++ b/WWDC/Tahoe/SessionList/SessionItemView.swift @@ -0,0 +1,242 @@ +// +// SessionItemView.swift +// WWDC +// +// Created by luca on 07.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// +import SwiftUI + +struct SessionItemViewForSidebar: View { + @Environment(SessionItemViewModel.self) var viewModel + + var body: some View { + NewSessionItemView(viewModel: viewModel) + } +} + +struct SessionItemViewForDetail: View { + let session: SessionViewModel + @State private var viewModel = SessionItemViewModel() + + var body: some View { + NewSessionItemView(viewModel: viewModel, horizontalPadding: 5) + .task { + viewModel.session = session + } + } +} + +struct NewSessionItemView: View { + let viewModel: SessionItemViewModel + var horizontalPadding: CGFloat = 0 + + var body: some View { + HStack(spacing: 0) { + ProgressView(value: viewModel.progress, total: 1.0) + .progressViewStyle(TrackColorProgressViewStyle()) + .foregroundStyle(.primary) + .opacity(viewModel.isWatched ? 0 : 1) + + SessionCoverView(coverImageURL: viewModel.coverImageURL, isThumbnail: true) { newImg, isPlaceHolder in + newImg + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 85, height: 48) + .clipped() + .padding(.horizontal, 8) + } + + informationLabels + } + .overlay(alignment: .trailing) { + statusIcons + } + .padding(.horizontal, horizontalPadding) + .padding(.vertical, 8) + .frame(height: 64) // Metrics.itemHeight + .contentShape(Rectangle()) // quick hover + .help([viewModel.title, viewModel.subtitle, viewModel.context].joined(separator: "\n")) + } + + /// Discover Apple-Hosted Background Assets + /// WWDC25 • Session 325 + /// System Services + private var informationLabels: some View { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + + Text(viewModel.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Spacer() + Text(viewModel.context) + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .truncationMode(.tail) + } + + private var statusIcons: some View { + // Icons + VStack(alignment: .trailing, spacing: 0) { + if viewModel.isFavorite { + Image(systemName: "star.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14) + .padding(3) + .blurBackground() + .transition(.scale) + } + + Spacer() + + if viewModel.isDownloaded { + Image(systemName: "arrowshape.down.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11) + .padding(3) + .blurBackground() + .transition(.scale) + } + } + .animation(.bouncy, value: viewModel.isFavorite) + .animation(.bouncy, value: viewModel.isDownloaded) + } +} + +struct SessionItemView: View { + @Environment(SessionItemViewModel.self) var viewModel + var horizontalPadding: CGFloat = 0 + + var body: some View { + HStack(spacing: 0) { + ProgressView(value: viewModel.progress, total: 1.0) + .progressViewStyle(TrackColorProgressViewStyle()) + .foregroundStyle(.primary) + .opacity(viewModel.isWatched ? 0 : 1) + + SessionCoverView(isThumbnail: true) { newImg, isPlaceHolder in + newImg + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 85, height: 48) + .clipped() + .padding(.horizontal, 8) + } + + informationLabels + } + .overlay(alignment: .trailing) { + statusIcons + } + .padding(.horizontal, horizontalPadding) + .padding(.vertical, 8) + .frame(height: 64) // Metrics.itemHeight + .task { + viewModel.prepareForDisplay() + } + .contentShape(Rectangle()) // quick hover + .help([viewModel.title, viewModel.subtitle, viewModel.context].joined(separator: "\n")) + } + + /// Discover Apple-Hosted Background Assets + /// WWDC25 • Session 325 + /// System Services + private var informationLabels: some View { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + + Text(viewModel.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Spacer() + Text(viewModel.context) + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .truncationMode(.tail) + } + + private var statusIcons: some View { + // Icons + VStack(alignment: .trailing, spacing: 0) { + if viewModel.isFavorite { + Image(systemName: "star.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14) + .padding(3) + .blurBackground() + .transition(.scale) + } + + Spacer() + + if viewModel.isDownloaded { + Image(systemName: "arrowshape.down.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11) + .padding(3) + .blurBackground() + .transition(.scale) + } + } + .animation(.bouncy, value: viewModel.isFavorite) + .animation(.bouncy, value: viewModel.isDownloaded) + } +} + +struct SessionItemButtonStyle: ButtonStyle { + @State private var isHovered = false + @Environment(\.isSelected) var isSelected + let style: SessionCellView.Style + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1) // scale content only + .contentShape(Rectangle()) + .background(background, in: .rect) + .animation(.bouncy, value: configuration.isPressed) + .animation(.smooth, value: isHovered) + .animation(.smooth, value: isSelected) + .transition(.blurReplace) + .clipShape(RoundedRectangle(cornerRadius: style == .rounded ? 6 : 0)) + .onHover { isHovering in + withAnimation { + isHovered = isHovering + } + } + } + + private var background: Color { + if isSelected { + return Color(.selection) + } else if isHovered { + return .secondary.opacity(0.3) + } else { + return .clear + } + } +} + +extension View { + func blurBackground(opacity: Double = 0.5) -> some View { + background { + Rectangle().fill(.background.opacity(opacity)) + .blur(radius: 5) + } + } +} diff --git a/WWDC/Tahoe/SessionList/SessionItemViewModel.swift b/WWDC/Tahoe/SessionList/SessionItemViewModel.swift new file mode 100644 index 00000000..1ca8b02d --- /dev/null +++ b/WWDC/Tahoe/SessionList/SessionItemViewModel.swift @@ -0,0 +1,280 @@ +// +// SessionItemViewModel.swift +// WWDC +// +// Created by luca on 07.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import ConfCore +import SwiftUI + +@Observable class SessionItemViewModel: Identifiable { + var id: String { session?.identifier ?? "" } + var session: SessionViewModel? { + didSet { + if session?.identifier != oldValue?.identifier { + prepareForDisplay() + } + } + } + + @ObservationIgnored private var observers = Set() + + var progress: Double = 0 + var isWatched: Bool { + progress >= Constants.watchedVideoRelativePosition + } + + var contextColor: NSColor = .clear + var title = "" + var subtitle = "" + var coverImageURL: URL? + var summary = "" + var footer = "" + var context = "" + var isFavorite = false + var isDownloaded = false + + var isMediaAvailable: Bool = false + var isPlaying = false + + var isTranscriptAvailable: Bool = false + + // MARK: - Actions + + var slidesButtonIsHidden: Bool = false + var calendarButtonIsHidden: Bool = false + var downloadState: SessionActionsViewModel.DownloadState = .notDownloadable + + var relatedSessions: [SessionViewModel] = [] + init(session: SessionViewModel? = nil) { + self.session = session + } + + func prepareForDisplay() { + observers = [] + updateOverviewBindings() + updateActionBindings() + } +} + +// MARK: - Overview + +private extension SessionItemViewModel { + func updateOverviewBindings() { + guard let session else { + return + } + session.rxProgresses.replaceErrorWithEmpty() + .compactMap(\.first?.relativePosition) + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.progress = newValue + } + } + .store(in: &observers) + session.rxColor.removeDuplicates() + .replaceError(with: .clear) + .sink { [weak self] newValue in + withAnimation { + self?.contextColor = newValue + } + } + .store(in: &observers) + session.rxImageUrl.replaceErrorWithEmpty() + .removeDuplicates() + .sink { [weak self] newValue in + self?.coverImageURL = newValue + } + .store(in: &observers) + + title = session.session.title + session.rxTitle.replaceError(with: "") + .removeDuplicates() + .sink { [weak self] newValue in + self?.title = newValue + } + .store(in: &observers) + session.rxSubtitle.replaceError(with: "") + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.subtitle = newValue + } + } + .store(in: &observers) + summary = session.session.summary + session.rxSummary.replaceError(with: "") + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.summary = newValue + } + } + .store(in: &observers) + session.rxFooter.replaceError(with: "") + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.footer = newValue + } + } + .store(in: &observers) + session.rxContext.replaceError(with: "") + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.context = newValue + } + } + .store(in: &observers) + + isFavorite = session.session.isFavorite + session.rxIsFavorite.replaceError(with: false) + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.isFavorite = newValue + } + } + .store(in: &observers) + isDownloaded = session.session.isDownloaded + session.rxIsDownloaded.replaceError(with: false) + .removeDuplicates() + .sink { [weak self] newValue in + withAnimation { + self?.isDownloaded = newValue + } + } + .store(in: &observers) + session.rxRelatedSessions + .replaceErrorWithEmpty() + .map { + $0.compactMap { + $0.session.flatMap(SessionViewModel.init(session:)) + } + } + .sink { [weak self] newValue in + withAnimation { + self?.relatedSessions = newValue.uniqueSessions() + } + } + .store(in: &observers) + session.rxTranscript + .replaceError(with: nil) + .map { $0 != nil } + .sink { [weak self] newValue in + self?.isTranscriptAvailable = newValue + } + .store(in: &observers) + session.rxCanBePlayed + .replaceError(with: false) + .sink { [weak self] newValue in + self?.isMediaAvailable = newValue + } + .store(in: &observers) + } +} + +private extension Array where Element == SessionViewModel { + func uniqueSessions() -> [SessionViewModel] { + var results: [SessionViewModel] = [] + for session in self { + if !results.contains(where: { $0.identifier == session.identifier }) { + results.append(session) + } + } + return results + } +} + +// MARK: - Actions + +private extension SessionItemViewModel { + func updateActionBindings() { + guard let session else { + return + } + slidesButtonIsHidden = (session.session.asset(ofType: .slides) == nil) + calendarButtonIsHidden = (session.sessionInstance.startTime < today()) + + let downloadID = session.session.downloadIdentifier + + /// Initial state + DispatchQueue.main.async { + self.downloadState = SessionActionsViewModel.downloadState( + session: session.session, + downloadState: MediaDownloadManager.shared.downloads.first { $0.id == downloadID }?.state + ) + } + + /// `true` if the session has already been downloaded. + let alreadyDownloaded: AnyPublisher = session.session + .valuePublisher(keyPaths: ["isDownloaded"]) + .replaceErrorWithEmpty() + .eraseToAnyPublisher() + + /// Emits subscribes to the downloads and then if 1 is added that matches our session, subscribes to the state of that download + let downloadStateSignal: AnyPublisher = MediaDownloadManager.shared.$downloads + .map { $0.first(where: { $0.id == downloadID }) } + .removeDuplicates() + .map { download in + guard let download else { + // no download -> no state + return Just(nil).eraseToAnyPublisher() + } + + return download.$state.map(Optional.some).eraseToAnyPublisher() + } + .switchToLatest() + .eraseToAnyPublisher() + + /// Combined stream that emits whenever any relevant state changes + Publishers.CombineLatest(alreadyDownloaded, downloadStateSignal) + .map(SessionActionsViewModel.downloadState(session:downloadState:)) + .sink { [weak self] newState in + withAnimation { + self?.downloadState = newState + } + } + .store(in: &observers) + } +} + +@available(macOS 26.0, *) +extension SessionItemViewModel { + @MainActor func toggleFavorite() { + coordinator?.sessionActionsDidSelectFavorite(nil) + } + + @MainActor func showSlides() { + coordinator?.sessionActionsDidSelectSlides(nil) + } + + @MainActor func download() { + coordinator?.sessionActionsDidSelectDownload(nil) + } + + @MainActor func addCalendar() { + coordinator?.sessionActionsDidSelectCalendar(nil) + } + + @MainActor func deleteDownload() { + coordinator?.sessionActionsDidSelectDeleteDownload(nil) + } + + @MainActor func share() { + coordinator?.sessionActionsDidSelectShare(nil) + } + + @MainActor func shareClip() { + coordinator?.sessionActionsDidSelectShareClip(nil) + } + + @MainActor func cancelDownload() { + coordinator?.sessionActionsDidSelectCancelDownload(nil) + } +} diff --git a/WWDC/Tahoe/SessionList/SessionListView.swift b/WWDC/Tahoe/SessionList/SessionListView.swift new file mode 100644 index 00000000..4eef0bad --- /dev/null +++ b/WWDC/Tahoe/SessionList/SessionListView.swift @@ -0,0 +1,164 @@ +// +// SessionListView.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +@available(macOS 26.0, *) +struct SessionListView: View { + @Environment(SessionListViewModel.self) var viewModel + @Environment(\.coordinator) var coordinator + @FocusState private var isListFocused: Bool + + var body: some View { + ScrollViewReader { proxy in + @Bindable var viewModel = viewModel + List(viewModel.sections, selection: $viewModel.selectedSessions) { section in + Section { + ForEach(section.sessions) { session in + SessionItemView() + .environment(session.model) + .id(session) + } + } header: { + Group { + if let symbol = section.systemSymbol { + Label(section.title, systemImage: symbol) + .labelIconToTitleSpacing(5) + } else { + Text(section.title) + } + } + .lineLimit(1) + .font(.headline) + } + .listRowInsets(.all, 0) + } + .focused($isListFocused) + .contextMenu(forSelectionType: SessionListSection.Session.self) { items in + contextMenus(for: items) + } + .onChange(of: viewModel.focusedSession) { oldValue, newValue in + if let newValue { + proxy.scrollTo(newValue, anchor: .center) + viewModel.focusedSession = nil + } + } + } + .task { + viewModel.prepareForDisplay() + isListFocused = true + } + } + + @ViewBuilder + private func contextMenus(for targetSessions: Set) -> some View { + // model here is pure for making swiftui update this menu, if something changed +// watchedMenus(targetSessions: targetSessions) +// +// Divider() +// +// favouritesMenus(targetSessions: targetSessions) +// +// Divider() +// +// downloadsMenus(targetSessions: targetSessions) + } + +// @ViewBuilder +// private func watchedMenus(targetSessions: Set) -> some View { +// let canMarkAsWatchedSessions = targetSessions.allIfContains { +// let canMarkAsWatched = !$0.model.session.session.isWatched +// && $0.model.session.session.instances.first?.isCurrentlyLive != true +// && $0.model.session.session.asset(ofType: .streamingVideo) != nil +// return canMarkAsWatched +// } +// let watchedTitle = canMarkAsWatchedSessions.count > 1 ? "Mark \(canMarkAsWatchedSessions.count) Sessions as Watched" : "Mark as Watched" +// Button(watchedTitle, systemImage: "rectangle.badge.checkmark") { +// coordinator?.sessionTableViewContextMenuActionWatch(viewModels: canMarkAsWatchedSessions.map(\.model.session)) +// } +// .disabled(canMarkAsWatchedSessions.isEmpty) +// +// let canMarkAsUnWatchedSessions = targetSessions.allIfContains { +// $0.model.session.session.isWatched || $0.model.session.session.progresses.count > 0 +// } +// let unwatchedTitle = canMarkAsUnWatchedSessions.count > 1 ? "Mark \(canMarkAsUnWatchedSessions.count) Sessions as Unwatched" : "Mark as Unwatched" +// Button(unwatchedTitle, systemImage: "rectangle.badge.minus") { +// coordinator?.sessionTableViewContextMenuActionUnWatch(viewModels: canMarkAsUnWatchedSessions.map(\.model.session)) +// } +// .disabled(canMarkAsUnWatchedSessions.isEmpty) +// } +// +// @ViewBuilder +// private func favouritesMenus(targetSessions: Set) -> some View { +// let canMarkFavouriteSessions = targetSessions.allIfContains { +// !$0.model.isFavorite +// } +// let addToFavouritesTitle = canMarkFavouriteSessions.count > 1 ? "Add \(canMarkFavouriteSessions.count) Sessions to Favorites" : "Add to Favorites" +// Button(addToFavouritesTitle, systemImage: "star") { +// coordinator?.sessionTableViewContextMenuActionFavorite(viewModels: canMarkFavouriteSessions.map(\.model.session)) +// } +// .disabled(canMarkFavouriteSessions.isEmpty) +// +// let canRemoveFavouriteSessions = targetSessions.allIfContains { +// $0.model.isFavorite +// } +// let removeFormFavouritesTitle = canRemoveFavouriteSessions.count > 1 ? "Remove \(canRemoveFavouriteSessions.count) Sessions from Favorites" : "Remove from Favorites" +// Button(removeFormFavouritesTitle, systemImage: "star.slash") { +// coordinator?.sessionTableViewContextMenuActionRemoveFavorite(viewModels: canRemoveFavouriteSessions.map(\.model.session)) +// } +// .disabled(canRemoveFavouriteSessions.isEmpty) +// } +// +// @ViewBuilder +// private func downloadsMenus(targetSessions: Set) -> some View { +// let downloadableSessions = targetSessions.filter { MediaDownloadManager.shared.canDownloadMedia(for: $0.model.session.session) && +// !MediaDownloadManager.shared.isDownloadingMedia(for: $0.model.session.session) && +// !MediaDownloadManager.shared.hasDownloadedMedia(for: $0.model.session.session) +// } +// let downloadTitle = downloadableSessions.count > 1 ? "Download \(downloadableSessions.count) Sessions" : "Download" +// Button(downloadTitle, systemImage: "arrow.down.document") { +// coordinator?.sessionTableViewContextMenuActionDownload(viewModels: downloadableSessions.map(\.model.session)) +// } +// .disabled(downloadableSessions.isEmpty) +// +// let removableSessions = targetSessions.filter { $0.model.session.session.isDownloaded +// } +// let removeDownloadTitle = removableSessions.count > 1 ? "Remove Download of \(removableSessions.count) Sessions" : "Remove Download" +// Button(removeDownloadTitle, systemImage: "trash") { +// coordinator?.sessionTableViewContextMenuActionRemoveDownload(viewModels: removableSessions.map(\.model.session)) +// } +// .disabled(removableSessions.isEmpty) +// +// let cancellableSessions = targetSessions.filter { MediaDownloadManager.shared.canDownloadMedia(for: $0.model.session.session) && MediaDownloadManager.shared.isDownloadingMedia(for: $0.model.session.session) +// } +// let cancelDownloadTitle = cancellableSessions.count > 1 ? "Cancel Download of \(cancellableSessions.count) Sessions" : "Cancel Download" +// Button(cancelDownloadTitle, systemImage: "arrow.down.circle.badge.xmark") { +// coordinator?.sessionTableViewContextMenuActionCancelDownload(viewModels: cancellableSessions.map(\.model.session)) +// } +// .disabled(cancellableSessions.isEmpty) +// +// let downloadedSessions = targetSessions.filter { MediaDownloadManager.shared.hasDownloadedMedia(for: $0.model.session.session) +// } +// let revealInFinderTitle = downloadedSessions.count > 1 ? "Show \(downloadedSessions.count) Sessions in Finder" : "Show in Finder" // Similar to Xcode +// Button(revealInFinderTitle, systemImage: "finder") { +// coordinator?.sessionTableViewContextMenuActionRevealInFinder(viewModels: downloadedSessions.map(\.model.session)) +// } +// .disabled(downloadedSessions.isEmpty) +// } +} + +private extension Collection where Element == SessionListSection.Session { + func allIfContains(where isIncluded: (_ element: Element) -> Bool) -> [Element] { + if contains(where: isIncluded) { + return Array(self) + } else { + return [] + } + } +} diff --git a/WWDC/Tahoe/SessionList/SessionListViewModel.swift b/WWDC/Tahoe/SessionList/SessionListViewModel.swift new file mode 100644 index 00000000..4e06b1d8 --- /dev/null +++ b/WWDC/Tahoe/SessionList/SessionListViewModel.swift @@ -0,0 +1,186 @@ +// +// SessionListViewModel.swift +// WWDC +// +// Created by luca on 06.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Combine +import SwiftUI + +struct SessionListSection: Identifiable, Equatable { + var id: [String?] { + [systemSymbol, title] + } + + let title: String + let systemSymbol: String? + var sessions: [Session] + + struct Session: Hashable, Identifiable { + let id: String + let model: SessionItemViewModel + let indexOfAllSessions: Int // section+items + + init(model: SessionViewModel, index indexOfAllSessions: Int) { + id = model.sessionIdentifier + self.model = .init(session: model) + self.indexOfAllSessions = indexOfAllSessions + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: SessionListSection.Session, rhs: SessionListSection.Session) -> Bool { + lhs.id == rhs.id + } + } +} + +@Observable class SessionListViewModel { + @ObservationIgnored let rowProvider: SessionRowProvider + @ObservationIgnored var initialSelection: SessionIdentifiable? + @ObservationIgnored let searchCoordinator: GlobalSearchCoordinator + + @ObservationIgnored private var rowsObserver: AnyCancellable? + + var sections: [SessionListSection] = [] + /// for detail view + var selectedSession: SessionListSection.Session? { + didSet { + syncSelectedSession() + } + } + /// for auto scroll + var focusedSession: SessionListSection.Session? + + /// for list view + var selectedSessions: Set = [] { + willSet { + updateSelectedSession(with: newValue) + } + } + + @ObservationIgnored @Published var isReady = false + + init( + searchCoordinator: GlobalSearchCoordinator, + rowProvider: SessionRowProvider, + initialSelection: SessionIdentifiable? + ) { + self.rowProvider = rowProvider + self.initialSelection = initialSelection + self.searchCoordinator = searchCoordinator + } + + func prepareForDisplay() { + updateSections(rowProvider.rows?.visibleRows.grouped() ?? []) + rowsObserver = rowProvider + .rowsPublisher + .map { $0.visibleRows.grouped() } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.updateSections($0) + } + rowProvider.startup() + } + + private func updateSections(_ newSections: [SessionListSection]) { + sections = newSections + isReady = true + if selectedSessions.isEmpty, let selection = newSections.flatMap(\.sessions) + .first(where: { $0.id == initialSelection?.sessionIdentifier }) + { + selectedSessions.insert(selection) + focusedSession = selection + initialSelection = nil + } + if selectedSessions.isEmpty, let firstSession = newSections.first?.sessions.first { + selectedSessions.insert(firstSession) + } + syncSelectedSession() + } + + private func syncSelectedSession() { + if #available(macOS 26.0, *) { + DispatchQueue.main.async { + self.coordinator?.detailViewModel.session = self.selectedSession?.model.session + } + } + } + + private func updateSelectedSession(with newValue: Set) { + let difference = newValue.symmetricDifference(selectedSessions) + guard let lastChange = difference.sorted(by: { $0.indexOfAllSessions < $1.indexOfAllSessions }).last else { + return + } + if selectedSessions.contains(lastChange) { + // removed + if lastChange.id == selectedSession?.id { + // removed the session current showing + // select last in the section row + selectedSession = newValue.sorted(by: { $0.indexOfAllSessions < $1.indexOfAllSessions }).last + } else { + // no need to change what's showing in detail + } + } else { + // newly inserted + selectedSession = lastChange + } + } +} + +// MARK: - Selection + +extension SessionListViewModel { + private func targetSession(for identifier: String) -> SessionListSection.Session? { + sections.flatMap(\.sessions).first(where: { $0.id == identifier }) + } + + func canDisplay(session: SessionIdentifiable) -> Bool { + targetSession(for: session.sessionIdentifier) != nil + } + + func select(session: SessionIdentifiable, removingFiltersIfNeeded: Bool) { + guard let target = targetSession(for: session.sessionIdentifier) else { + // not yet loaded + initialSelection = session + return + } + selectedSessions = [target] + focusedSession = target + if removingFiltersIfNeeded { + searchCoordinator.resetAction.send() + } + } +} + +private extension Array where Element == SessionRow { + func grouped() -> [SessionListSection] { + var sections = [SessionListSection]() + var currentSection: SessionListSection? + var currentSessionIndex = 0 + + for row in self { + switch row.kind { + case let .sectionHeader(title, symbol): + currentSection.flatMap { sections.append($0) } + currentSection = .init(title: title, systemSymbol: symbol, sessions: []) + case let .session(viewModel): + currentSection?.sessions.append(.init(model: viewModel, index: currentSessionIndex)) + currentSessionIndex += 1 + } + } + currentSection.flatMap { sections.append($0) } + return sections + } +} + +private extension SessionRows { + var visibleRows: [SessionRow] { + filtered.isEmpty ? all : filtered + } +} diff --git a/WWDC/Tahoe/SessionList/ViewControllerWrapper.swift b/WWDC/Tahoe/SessionList/ViewControllerWrapper.swift new file mode 100644 index 00000000..914fd313 --- /dev/null +++ b/WWDC/Tahoe/SessionList/ViewControllerWrapper.swift @@ -0,0 +1,24 @@ +// +// ViewControllerWrapper.swift +// WWDC +// +// Created by luca on 09.08.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import SwiftUI + +struct ViewControllerWrapper: NSViewControllerRepresentable { + let viewController: NSViewController + var additionalSafeAreaInsets: EdgeInsets? + func makeNSViewController(context: Context) -> NSViewController { + viewController + } + + func updateNSViewController(_ nsViewController: NSViewController, context: Context) { + if let inset = additionalSafeAreaInsets { + nsViewController.viewIfLoaded?.additionalSafeAreaInsets = .init(top: inset.top, left: inset.leading, bottom: inset.bottom, right: inset.trailing) + } + } +} diff --git a/WWDC/Tahoe/TahoeFeatureFlag.swift b/WWDC/Tahoe/TahoeFeatureFlag.swift new file mode 100644 index 00000000..11875a1f --- /dev/null +++ b/WWDC/Tahoe/TahoeFeatureFlag.swift @@ -0,0 +1,32 @@ +// +// TahoeFeatureFlag.swift +// WWDC +// +// Created by luca on 29.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import Foundation +import SwiftUI + +enum TahoeFeatureFlag { + static var isLiquidGlassAvailable: Bool { +#if compiler(>=6.2) && canImport(FoundationModels) + return true +#else + return false +#endif + } + + static var isLiquidGlassEnabled: Bool { + get { + guard isLiquidGlassAvailable else { + return false + } + return UserDefaults.standard.bool(forKey: "TahoeFeatureFlag.isLiquidGlassEnabled") + } + set { + UserDefaults.standard.set(newValue, forKey: "TahoeFeatureFlag.isLiquidGlassEnabled") + } + } +} diff --git a/WWDC/Tahoe/ToolbarSetup.swift b/WWDC/Tahoe/ToolbarSetup.swift new file mode 100644 index 00000000..218f78f9 --- /dev/null +++ b/WWDC/Tahoe/ToolbarSetup.swift @@ -0,0 +1,53 @@ +// +// ToolbarSetup.swift +// WWDC +// +// Created by luca on 29.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit + +extension NSToolbarItem.Identifier { + static let searchItem = NSToolbarItem.Identifier("wwdc.sidebar.search") + static let filterItem = NSToolbarItem.Identifier("wwdc.sidebar.search.filter") + static let tabSelectionItem = NSToolbarItem.Identifier("wwdc.main.centered.tab") + static let downloadItem = NSToolbarItem.Identifier("wwdc.main.download") +} + +protocol ToolbarItemAccessor { +} + +extension ToolbarItemAccessor { + var toolbarWindow: NSWindow? { + NSApp.keyWindow + } +} + +extension ToolbarItemAccessor { + var topSegmentControl: NSSegmentedControl? { + topTabItem?.view as? NSSegmentedControl + } + + var topTabItem: NSToolbarItem? { + toolbarWindow?.toolbar?.items.first(where: { $0.itemIdentifier == .tabSelectionItem }) + } + + var filterItem: NSMenuToolbarItem? { + toolbarWindow?.toolbar?.items.first(where: { $0.itemIdentifier == .filterItem }) as? NSMenuToolbarItem + } + + var searchItem: NSSearchToolbarItem? { + toolbarWindow?.toolbar?.items.first(where: { $0.itemIdentifier == .searchItem }) as? NSSearchToolbarItem + } + + var downloadItem: NSToolbarItem? { + toolbarWindow?.toolbar?.items.first(where: { $0.itemIdentifier == .downloadItem }) as? NSToolbarItem + } +} + +extension NSViewController: ToolbarItemAccessor { +} + +extension NSWindowController: ToolbarItemAccessor { +} diff --git a/WWDC/TopicHeaderRow.swift b/WWDC/TopicHeaderRow.swift index a6c56703..6d4d9058 100644 --- a/WWDC/TopicHeaderRow.swift +++ b/WWDC/TopicHeaderRow.swift @@ -11,22 +11,21 @@ import SwiftUI final class TopicHeaderRow: NSTableRowView { - var content: TopicHeaderRowContent? { - didSet { - guard content != oldValue else { return } + private lazy var viewModel = HeaderRowViewModel(title: "") - update() - } + var title: String { + get { viewModel.title } + set { viewModel.title = newValue } } - var title: String? { - didSet { - guard title != oldValue else { return } - } + var symbolName: String? { + get { viewModel.symbolName } + set { viewModel.symbolName = newValue } } override init(frame frameRect: NSRect) { super.init(frame: frameRect) + update() } required init?(coder: NSCoder) { @@ -37,19 +36,7 @@ final class TopicHeaderRow: NSTableRowView { override func drawBackground(in dirtyRect: NSRect) { } - private var contentView: NSHostingView? - private func update() { - guard let content else { - contentView?.isHidden = true - return - } - - if let contentView { - contentView.rootView = content - contentView.isHidden = false - return - } let bg = NSVisualEffectView(frame: bounds) bg.appearance = NSAppearance(named: .darkAqua) @@ -59,7 +46,7 @@ final class TopicHeaderRow: NSTableRowView { bg.autoresizingMask = [.width, .height] addSubview(bg) - let v = NSHostingView(rootView: content) + let v = NSHostingView(rootView: TopicHeaderRowContent().environment(viewModel)) v.translatesAutoresizingMaskIntoConstraints = false bg.addSubview(v) @@ -70,25 +57,31 @@ final class TopicHeaderRow: NSTableRowView { v.topAnchor.constraint(equalTo: topAnchor), v.bottomAnchor.constraint(equalTo: bottomAnchor) ]) - - contentView = v } } -struct TopicHeaderRowContent: View, Hashable { +@Observable +private class HeaderRowViewModel { var title: String var symbolName: String? + init(title: String, symbolName: String? = nil) { + self.title = title + self.symbolName = symbolName + } +} +private struct TopicHeaderRowContent: View { + @Environment(HeaderRowViewModel.self) private var viewModel var body: some View { HStack { - if let symbolName { + if let symbolName = viewModel.symbolName { Image(systemName: symbolName) .foregroundStyle(.secondary) .symbolVariant(.fill) } - Text(title) + Text(viewModel.title) .font(.headline) .foregroundStyle(.primary) } diff --git a/WWDC/VideoPlayerViewController.swift b/WWDC/VideoPlayerViewController.swift index 06665226..ee400aae 100644 --- a/WWDC/VideoPlayerViewController.swift +++ b/WWDC/VideoPlayerViewController.swift @@ -66,7 +66,7 @@ final class VideoPlayerViewController: NSViewController { } lazy var playerView: PUIPlayerView = { - return PUIPlayerView(player: self.player) + return PUIPlayerView(player: self.player, shouldAdoptLiquidGlass: TahoeFeatureFlag.isLiquidGlassEnabled) }() fileprivate lazy var progressIndicator: NSProgressIndicator = { @@ -76,7 +76,7 @@ final class VideoPlayerViewController: NSViewController { p.style = .spinning p.isIndeterminate = true p.translatesAutoresizingMaskIntoConstraints = false - p.appearance = NSAppearance(named: NSAppearance.Name(rawValue: "WhiteSpinner")) + p.appearance = NSAppearance(named: .darkAqua) p.isHidden = true p.sizeToFit() @@ -97,9 +97,18 @@ final class VideoPlayerViewController: NSViewController { playerView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + var progressIndicator: NSView = self.progressIndicator + if #available(macOS 26.0, *), TahoeFeatureFlag.isLiquidGlassEnabled { + progressIndicator = self.progressIndicator.glassCircleEffect(.clear, background: .init(nsColor: .textBackgroundColor).opacity(0.5)) + progressIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + progressIndicator.widthAnchor.constraint(equalToConstant: 40), + progressIndicator.heightAnchor.constraint(equalToConstant: 40) + ]) + } view.addSubview(progressIndicator) view.addConstraints([ - NSLayoutConstraint(item: progressIndicator, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0), + NSLayoutConstraint(item: progressIndicator, attribute: .centerX, relatedBy: .equal, toItem: view.safeAreaLayoutGuide, attribute: .centerX, multiplier: 1.0, constant: 0.0), NSLayoutConstraint(item: progressIndicator, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: 0.0) ]) @@ -243,6 +252,7 @@ final class VideoPlayerViewController: NSViewController { guard self?.player.timeControlStatus == .waitingToPlayAtSpecifiedRate else { return } self?.progressIndicator.startAnimation(nil) self?.progressIndicator.isHidden = false + self?.playerView.hideAllControls = true } } @@ -250,6 +260,7 @@ final class VideoPlayerViewController: NSViewController { if !progressIndicator.isHidden { progressIndicator.stopAnimation(nil) progressIndicator.isHidden = true + playerView.hideAllControls = false } } @@ -281,7 +292,9 @@ final class VideoPlayerViewController: NSViewController { let ct = CMTimeGetSeconds(self.player.currentTime()) let roundedTimecode = Transcript.roundedStringFromTimecode(ct) - NotificationCenter.default.post(name: .HighlightTranscriptAtCurrentTimecode, object: roundedTimecode) + NotificationCenter.default.post(name: .HighlightTranscriptAtCurrentTimecode, object: roundedTimecode, userInfo: [ + "session_id": sessionViewModel.sessionIdentifier + ]) } } @@ -339,7 +352,10 @@ extension VideoPlayerViewController: PUIPlayerViewDelegate { } func playerViewWillEnterPictureInPictureMode(_ playerView: PUIPlayerView) { - + // when entering pip, automatically exit full screen if needed + if playerView.isInFullScreenPlayerWindow, let playerWindow = playerView.window as? PUIPlayerWindow { + playerWindow.toggleFullScreen(self) + } } func playerViewDidSelectLike(_ playerView: PUIPlayerView) { @@ -393,6 +409,27 @@ extension VideoPlayerViewController: PUIPlayerViewAppearanceDelegate { func dismissDetachedStatus(_ status: DetachedPlaybackStatus, for playerView: PUIPlayerView) { shelf?.dismissDetachedStatus(status, for: playerView) } + + func playerViewWillHidePlayControls(_ playerView: PUIPlayerView) { + guard TahoeFeatureFlag.isLiquidGlassEnabled else { return } + guard searchItem?.searchField.currentEditor() == nil else { + return + } + [searchItem, topTabItem, downloadItem].forEach { + if #available(macOS 15.0, *) { + $0?.isHidden = true + } + } + } + + func playerViewWillShowPlayControls(_ playerView: PUIPlayerView) { + guard TahoeFeatureFlag.isLiquidGlassEnabled else { return } + [searchItem, topTabItem, downloadItem].forEach { + if #available(macOS 15.0, *) { + $0?.isHidden = false + } + } + } } extension Transcript { diff --git a/WWDC/WWDCCoordinator.swift b/WWDC/WWDCCoordinator.swift new file mode 100644 index 00000000..89f1d2a0 --- /dev/null +++ b/WWDC/WWDCCoordinator.swift @@ -0,0 +1,78 @@ +// +// WWDCCoordinator.swift +// WWDC +// +// Created by luca on 30.07.2025. +// Copyright © 2025 Guilherme Rambo. All rights reserved. +// + +import AppKit +import Combine +import ConfCore +import PlayerUI + +@MainActor +protocol WWDCCoordinator: Logging, Signposting, ShelfViewControllerDelegate, PUITimelineDelegate, VideoPlayerViewControllerDelegate, SessionActionsDelegate, RelatedSessionsDelegate, SessionsTableViewControllerDelegate { + associatedtype TabController: WWDCTabController + var liveObserver: LiveObserver { get } + + var storage: Storage { get } + var syncEngine: SyncEngine { get } + + // - Top level controllers + var windowController: WWDCWindowControllerObject { get } + var tabController: TabController { get } + + var currentPlayerController: VideoPlayerViewController? { get set } + + var currentActivity: NSUserActivity? { get set } + + var activeTab: MainWindowTab { get } + + /// The tab that "owns" the current player (the one that was active when the "play" button was pressed) + var playerOwnerTab: MainWindowTab? { get set } + + /// The session that "owns" the current player (the one that was selected on the active tab when "play" was pressed) + var playerOwnerSessionIdentifier: String? { get set } + + /// Whether we're currently in the middle of a player context transition + var isTransitioningPlayerContext: Bool { get set } + + /// Whether we were playing the video when a clip sharing session begin, to restore state later. + var wasPlayingWhenClipSharingBegan: Bool { get set } + + var exploreTabLiveSession: AnyPublisher { get } + + /// The session that is currently selected on the videos tab (observable) + var videosSelectedSessionViewModel: SessionViewModel? { get } + + /// The session that is currently selected on the schedule tab (observable) + var scheduleSelectedSessionViewModel: SessionViewModel? { get } + + /// The selected session's view model, regardless of which tab it is selected in + var activeTabSelectedSessionViewModel: SessionViewModel? { get } + + /// The viewModel for the current playback session + var currentPlaybackViewModel: PlaybackViewModel? { get set } + + // MARK: - Shelf + + func select(session: SessionIdentifiable, removingFiltersIfNeeded: Bool) + func shelf(for tab: MainWindowTab) -> ShelfViewController? + func showClipUI() + + // MARK: - Related Sessions + + func selectSessionOnAppropriateTab(with viewModel: SessionViewModel) + + // MARK: - App Delegate + @discardableResult func receiveNotification(with userInfo: [String: Any]) -> Bool + func handle(link: DeepLink) + func showPreferences(_ sender: Any?) + func showAboutWindow() + func showExplore() + func showSchedule() + func showVideos() + func refresh(_ sender: Any?) + func applyFilter(state: WWDCFiltersState) +} diff --git a/WWDC/WWDCTabViewController.swift b/WWDC/WWDCTabViewController.swift index dff88191..422718bf 100644 --- a/WWDC/WWDCTabViewController.swift +++ b/WWDC/WWDCTabViewController.swift @@ -17,7 +17,28 @@ extension WWDCTab { var hidesWindowTitleBar: Bool { false } } -class WWDCTabViewController: NSTabViewController where Tab.RawValue == Int { +protocol WWDCTabController: NSViewController { + associatedtype Tab: WWDCTab + var activeTab: Tab { get set } + var activeTabPublisher: AnyPublisher { get } + func showLoading() + func hideLoading() +} + +extension WWDCTabController { + func setActiveTab(_ tab: T) { + guard let t = tab as? Tab else { + return + } + activeTab = t + } + + func activeTabPublisher(for: T.Type) -> AnyPublisher { + activeTabPublisher.compactMap({ $0 as? T }).eraseToAnyPublisher() + } +} + +class WWDCTabViewController: NSTabViewController, WWDCTabController where Tab.RawValue == Int { var activeTab: Tab { get { @@ -28,6 +49,9 @@ class WWDCTabViewController: NSTabViewController where Tab.RawValu } } + var activeTabPublisher: AnyPublisher { + $activeTabVar.eraseToAnyPublisher() + } @Published private(set) var activeTabVar = Tab(rawValue: 0)! @@ -64,7 +88,7 @@ class WWDCTabViewController: NSTabViewController where Tab.RawValu private(set) lazy var tabBar = WWDCTabViewControllerTabBar() - init(windowController: WWDCWindowController) { + init(windowController: WWDCWindowControllerObject) { super.init(nibName: nil, bundle: nil) windowController.titleBarViewController.tabBar = tabBar diff --git a/WWDC/WWDCWindowController.swift b/WWDC/WWDCWindowController.swift index 810692fb..f532718a 100644 --- a/WWDC/WWDCWindowController.swift +++ b/WWDC/WWDCWindowController.swift @@ -8,9 +8,14 @@ import Foundation -class WWDCWindowController: NSWindowController { +protocol WWDCWindowControllerObject: NSWindowController { + var sidebarInitWidth: CGFloat? { get set } + var titleBarViewController: TitleBarViewController { get } +} - var titleBarViewController = TitleBarViewController() +class WWDCWindowController: NSWindowController, WWDCWindowControllerObject { + public var sidebarInitWidth: CGFloat? + lazy var titleBarViewController = TitleBarViewController() override var windowNibName: NSNib.Name? { // Triggers `loadWindow` to be called so we can override it @@ -21,6 +26,7 @@ class WWDCWindowController: NSWindowController { super.init(window: nil) } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -31,7 +37,33 @@ class WWDCWindowController: NSWindowController { override func windowDidLoad() { super.windowDidLoad() - window?.addTitlebarAccessoryViewController(titleBarViewController) } } + +class NewWWDCWindowController: NSWindowController, WWDCWindowControllerObject { + static var defaultRect: NSRect { + return NSScreen.main?.visibleFrame.insetBy(dx: 50, dy: 120) ?? + NSRect(x: 0, y: 0, width: 1200, height: 600) + } + public var sidebarInitWidth: CGFloat? + lazy var titleBarViewController = TitleBarViewController() + + override var windowNibName: NSNib.Name? { + // Triggers `loadWindow` to be called so we can override it + return NSNib.Name("") + } + + init() { + super.init(window: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadWindow() { + fatalError("loadWindow must be overriden by subclasses") + } +}