Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,61 @@
//
// PlatformImage+Cropping.swift
// Yattee
//
// Cross-platform image cropping for extracting thumbnails from sprite sheets.
//
import Foundation
import CoreGraphics
#if os(macOS)
import AppKit
extension NSImage {
/// Crops the image to the specified rect.
/// - Parameter rect: The rect to crop in image coordinates (origin at top-left)
/// - Returns: The cropped image, or nil if cropping fails
func cropped(to rect: CGRect) -> NSImage? {
// Get CGImage representation
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
// NSImage coordinates have origin at bottom-left, but CGImage has origin at top-left
// The rect is already in top-left coordinate system, so use it directly
guard let croppedCGImage = cgImage.cropping(to: rect) else {
return nil
}
return NSImage(cgImage: croppedCGImage, size: rect.size)
}
}
#else
import UIKit
extension UIImage {
/// Crops the image to the specified rect.
/// - Parameter rect: The rect to crop in image coordinates (origin at top-left)
/// - Returns: The cropped image, or nil if cropping fails
func cropped(to rect: CGRect) -> UIImage? {
guard let cgImage = self.cgImage else { return nil }
// Scale rect for image scale (Retina displays)
let scale = self.scale
let scaledRect = CGRect(
x: rect.origin.x * scale,
y: rect.origin.y * scale,
width: rect.width * scale,
height: rect.height * scale
)
guard let croppedCGImage = cgImage.cropping(to: scaledRect) else {
return nil
}
return UIImage(cgImage: croppedCGImage, scale: scale, orientation: .up)
}
}
#endif

View File

@@ -0,0 +1,14 @@
//
// PlatformImage.swift
// Yattee
//
// Cross-platform image typealias.
//
#if os(macOS)
import AppKit
typealias PlatformImage = NSImage
#else
import UIKit
typealias PlatformImage = UIImage
#endif

View File

@@ -0,0 +1,18 @@
//
// Video+DeArrow.swift
// Yattee
//
// Extension for DeArrow title resolution on Video.
//
import Foundation
extension Video {
/// Returns the DeArrow-replaced title if available, otherwise the original title.
///
/// Usage: `video.displayTitle(using: appEnvironment?.deArrowBrandingProvider)`
@MainActor
func displayTitle(using provider: DeArrowBrandingProvider?) -> String {
provider?.title(for: self) ?? title
}
}

View File

@@ -0,0 +1,50 @@
//
// View+Conditional.swift
// Yattee
//
// Conditional view modifier extension.
//
import SwiftUI
extension View {
/// Conditionally applies a transformation to the view.
@ViewBuilder
func `if`<Transform: View>(
_ condition: Bool,
transform: (Self) -> Transform
) -> some View {
if condition {
transform(self)
} else {
self
}
}
/// Conditionally applies one of two transformations.
@ViewBuilder
func `if`<TrueContent: View, FalseContent: View>(
_ condition: Bool,
then trueTransform: (Self) -> TrueContent,
else falseTransform: (Self) -> FalseContent
) -> some View {
if condition {
trueTransform(self)
} else {
falseTransform(self)
}
}
/// Conditionally applies a transformation when an optional value is present.
@ViewBuilder
func ifLet<T, Transform: View>(
_ value: T?,
transform: (Self, T) -> Transform
) -> some View {
if let value {
transform(self, value)
} else {
self
}
}
}

View File

@@ -0,0 +1,150 @@
//
// View+LiquidGlassSheet.swift
// Yattee
//
// View modifiers for iOS 26 Liquid Glass morphing sheet transitions.
//
import SwiftUI
// MARK: - Transition Source Modifier (for Views)
/// View modifier that marks a view as the source for a morphing sheet transition.
/// Apply this to a Button inside a ToolbarItem to enable the morphing effect on iOS 26+.
struct LiquidGlassTransitionSourceModifier: ViewModifier {
let id: String
let namespace: Namespace.ID
func body(content: Content) -> some View {
#if os(iOS)
if #available(iOS 26, *) {
content
.matchedTransitionSource(id: id, in: namespace)
} else {
content
}
#else
content
#endif
}
}
// MARK: - Sheet Content Modifier (for sheet content)
/// View modifier that applies the zoom navigation transition to sheet content.
/// Apply this to the content inside a .sheet() to complete the morphing effect on iOS 26+.
struct LiquidGlassSheetContentModifier: ViewModifier {
let sourceID: String
let namespace: Namespace.ID
func body(content: Content) -> some View {
#if os(iOS)
if #available(iOS 26, *) {
content
.navigationTransition(.zoom(sourceID: sourceID, in: namespace))
} else {
content
}
#else
content
#endif
}
}
// MARK: - View Extensions
extension View {
/// Marks this view as the source for a Liquid Glass morphing sheet transition.
///
/// Apply this modifier to a Button (or other view) that presents a sheet. On iOS 26+,
/// the sheet will morph from this view when presented.
///
/// - Parameters:
/// - id: Unique identifier for the transition (must match the sheet content's sourceID).
/// - namespace: A namespace for the matched geometry effect.
///
/// Example:
/// ```swift
/// @Namespace private var sheetTransition
///
/// .toolbar {
/// ToolbarItem(placement: .primaryAction) {
/// Button { showSheet = true } label: {
/// Image(systemName: "gear")
/// }
/// .liquidGlassTransitionSource(id: "settings", in: sheetTransition)
/// }
/// }
/// ```
func liquidGlassTransitionSource(
id: String,
in namespace: Namespace.ID
) -> some View {
modifier(LiquidGlassTransitionSourceModifier(id: id, namespace: namespace))
}
/// Applies the Liquid Glass morphing transition to sheet content.
///
/// Apply this modifier to the content inside a `.sheet()` modifier. On iOS 26+,
/// the sheet will morph from the matched transition source when presented.
///
/// - Parameters:
/// - sourceID: Unique identifier matching the transition source's id.
/// - namespace: A namespace for the matched geometry effect (must match the source).
///
/// Example:
/// ```swift
/// .sheet(isPresented: $showSheet) {
/// SettingsView()
/// .liquidGlassSheetContent(sourceID: "settings", in: sheetTransition)
/// }
/// ```
func liquidGlassSheetContent(
sourceID: String,
in namespace: Namespace.ID
) -> some View {
modifier(LiquidGlassSheetContentModifier(sourceID: sourceID, namespace: namespace))
}
}
// MARK: - ToolbarContent Extension
extension ToolbarContent {
/// Marks this toolbar content as the source for a Liquid Glass morphing sheet transition.
///
/// Apply this modifier to a `ToolbarItem` that presents a sheet. On iOS 26+,
/// the sheet will morph from this toolbar item when presented.
///
/// - Parameters:
/// - id: Unique identifier for the transition (must match the sheet content's sourceID).
/// - namespace: A namespace for the matched geometry effect.
///
/// Example:
/// ```swift
/// @Namespace private var sheetTransition
///
/// .toolbar {
/// ToolbarItem(placement: .primaryAction) {
/// Button { showSheet = true } label: {
/// Image(systemName: "gear")
/// }
/// }
/// .liquidGlassTransitionSource(id: "settings", in: sheetTransition)
/// }
/// ```
@ToolbarContentBuilder
func liquidGlassTransitionSource(
id: String,
in namespace: Namespace.ID
) -> some ToolbarContent {
#if os(iOS)
if #available(iOS 26, *) {
self.matchedTransitionSource(id: id, in: namespace)
} else {
self
}
#else
self
#endif
}
}

View File

@@ -0,0 +1,36 @@
//
// View+NavigationSubtitle.swift
// Yattee
//
// Conditionally applies .navigationSubtitle on iOS 26+ and macOS 26+.
//
import SwiftUI
struct NavigationSubtitleModifier: ViewModifier {
let subtitle: String?
func body(content: Content) -> some View {
#if os(iOS)
if #available(iOS 26, *), let subtitle {
content.navigationSubtitle(subtitle)
} else {
content
}
#elseif os(macOS)
if #available(macOS 26, *), let subtitle {
content.navigationSubtitle(subtitle)
} else {
content
}
#else
content
#endif
}
}
extension View {
func navigationSubtitleIfAvailable(_ subtitle: String?) -> some View {
modifier(NavigationSubtitleModifier(subtitle: subtitle))
}
}

View File

@@ -0,0 +1,179 @@
//
// View+ZoomTransition.swift
// Yattee
//
// View modifiers for iOS 18 zoom navigation transitions.
// Note: Zoom transitions are only available on iOS. On other platforms,
// these modifiers have no effect but are still safe to use.
//
import SwiftUI
// MARK: - Environment Keys
/// Environment key to pass the navigation transition namespace through the view hierarchy.
private struct ZoomTransitionNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil
}
/// Environment key to control whether zoom transitions are enabled.
private struct ZoomTransitionsEnabledKey: EnvironmentKey {
static let defaultValue: Bool = true
}
extension EnvironmentValues {
/// The namespace used for zoom navigation transitions.
var zoomTransitionNamespace: Namespace.ID? {
get { self[ZoomTransitionNamespaceKey.self] }
set { self[ZoomTransitionNamespaceKey.self] = newValue }
}
/// Whether zoom transitions are enabled. Defaults to true.
var zoomTransitionsEnabled: Bool {
get { self[ZoomTransitionsEnabledKey.self] }
set { self[ZoomTransitionsEnabledKey.self] = newValue }
}
}
// MARK: - Transition Source Modifier
/// View modifier that marks a view as the source for a zoom navigation transition.
///
/// Apply this to a NavigationLink or the view it wraps. When the user navigates
/// to the destination, the view will animate with a zoom effect from this source.
/// Note: Only has an effect on iOS. On macOS and tvOS, returns the content unchanged.
struct ZoomTransitionSourceModifier<ID: Hashable>: ViewModifier {
let id: ID
@Environment(\.zoomTransitionNamespace) private var namespace
@Environment(\.zoomTransitionsEnabled) private var zoomTransitionsEnabled
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, let namespace {
content
.matchedTransitionSource(id: id, in: namespace)
} else {
content
}
#else
content
#endif
}
}
// MARK: - Transition Destination Modifier
/// View modifier that applies the zoom navigation transition to a destination view.
///
/// Apply this to the destination view of a NavigationLink. When navigating to this view,
/// it will animate with a zoom effect from the matched source.
/// Note: Only has an effect on iOS. On macOS and tvOS, returns the content unchanged.
struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
let id: ID
@Environment(\.zoomTransitionNamespace) private var namespace
@Environment(\.zoomTransitionsEnabled) private var zoomTransitionsEnabled
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, let namespace {
content
.navigationTransition(.zoom(sourceID: id, in: namespace))
} else {
content
}
#else
content
#endif
}
}
// MARK: - View Extensions
extension View {
/// Marks this view as the source for a zoom navigation transition.
///
/// Apply this modifier to a NavigationLink or the view it wraps.
/// The id must match the destination's transition id for the zoom effect to work.
///
/// Note: Only has an effect on iOS. Safe to use on all platforms.
///
/// - Parameter id: Unique identifier for the transition (e.g., video.id, channel.id).
/// - Returns: A view that serves as the source for the zoom transition.
///
/// Example:
/// ```swift
/// NavigationLink(value: NavigationDestination.channel(channel.id, source)) {
/// ChannelRowView(channel: channel)
/// }
/// .zoomTransitionSource(id: channel.id)
/// ```
func zoomTransitionSource<ID: Hashable>(id: ID) -> some View {
modifier(ZoomTransitionSourceModifier(id: id))
}
/// Applies the zoom navigation transition to this destination view.
///
/// Apply this modifier to the destination view of a NavigationLink.
/// The id must match the source's transition id for the zoom effect to work.
///
/// Note: Only has an effect on iOS. Safe to use on all platforms.
///
/// - Parameter id: Unique identifier matching the source's id.
/// - Returns: A view with the zoom transition applied.
///
/// Example:
/// ```swift
/// ChannelView(channel: channel)
/// .zoomTransitionDestination(id: channel.id)
/// ```
func zoomTransitionDestination<ID: Hashable>(id: ID) -> some View {
modifier(ZoomTransitionDestinationModifier(id: id))
}
/// Injects the zoom transition namespace into the environment.
///
/// Apply this modifier to a NavigationStack to enable zoom transitions
/// for all NavigationLinks within that stack.
///
/// - Parameter namespace: The namespace to use for matched transitions.
/// - Returns: A view with the namespace injected into the environment.
///
/// Example:
/// ```swift
/// @Namespace private var zoomTransition
///
/// NavigationStack {
/// ContentView()
/// }
/// .zoomTransitionNamespace(zoomTransition)
/// ```
func zoomTransitionNamespace(_ namespace: Namespace.ID) -> some View {
environment(\.zoomTransitionNamespace, namespace)
}
/// Injects the zoom transition namespace into the environment (optional overload).
///
/// If the namespace is nil, the view is returned unchanged.
///
/// - Parameter namespace: The optional namespace to use for matched transitions.
/// - Returns: A view with the namespace injected into the environment, or unchanged if nil.
@ViewBuilder
func zoomTransitionNamespace(_ namespace: Namespace.ID?) -> some View {
if let namespace {
environment(\.zoomTransitionNamespace, namespace)
} else {
self
}
}
/// Sets whether zoom transitions are enabled.
///
/// Apply this at a high level in the view hierarchy (e.g., ContentView) to control
/// whether zoom navigation transitions are applied throughout the app.
///
/// - Parameter enabled: Whether zoom transitions should be enabled.
/// - Returns: A view with the zoom transitions enabled state set.
func zoomTransitionsEnabled(_ enabled: Bool) -> some View {
environment(\.zoomTransitionsEnabled, enabled)
}
}