Skip to content

blaineam/MediaStream

Repository files navigation

MediaStream

Platform Swift License

A comprehensive SwiftUI package for displaying beautiful media galleries with advanced features including zoom, pan, slideshow, grid view with multi-select, video playback, and more.

✨ Features

πŸ–ΌοΈ Gallery Views

  • Slideshow View: Fullscreen media viewer with swipe navigation
  • Grid View: Browsing interface with thumbnails and filtering
  • Responsive Design: Adapts to screen size (3 wide on iPhone portrait, 4 on landscape)

🎯 Core Capabilities

  • βœ… Double-tap to zoom (1x to 4x with smooth animations)
  • βœ… Pinch-to-zoom gesture support
  • βœ… Pan gesture when zoomed in
  • βœ… Swipe navigation between media items
  • βœ… Caption support with toggle visibility
  • βœ… Share functionality (preserves original file formats)
  • βœ… Built-in iCloud download support

🎬 Slideshow Features

  • Configurable duration (default 5 seconds)
  • Automatic playback through images and videos
  • Smart pause when zoomed in
  • Automatic resume when zoomed out
  • Duration detection for animated images
  • Auto-disable idle timer (iOS): Prevents device from sleeping during slideshow playback

πŸ“± Media Type Support

  • Static Images: JPEG, PNG, HEIC, RAW (DNG, CR2, NEF, ARW), etc.
  • Animated Images: GIF, APNG, HEIF sequences, WebP
  • Videos: MP4, MOV, M4V, WebM with playback controls
  • Audio: MP3, AAC, M4A, FLAC, WAV with artwork and controls
  • Duration Display: Shows video/audio length and animated image duration

🎨 Grid View Features

  • Multi-Select Mode: Tap to select multiple items with visual indicators
  • Filtering: Built-in filter UI (All, Images, Videos, Audio, Animated)
  • Custom Filters: Apply your own filtering logic
  • Custom Sorting: Define custom sort order
  • Batch Operations: Share, delete, or perform custom actions on selected items

πŸ”§ Advanced Features

  • Platform-specific share sheets (iOS UIActivityViewController, macOS NSSharingServicePicker)
  • Custom action buttons API
  • Multi-select with custom bulk actions
  • Drag & drop support (macOS)
  • Cross-platform support (iOS & macOS)

🧠 Memory Optimization (v1.1.0)

  • LRU Thumbnail Cache: Automatic eviction of least-recently-used thumbnails with configurable memory limit (default 100MB)
  • Visibility-based Loading: Only loads thumbnails for items currently visible on screen
  • ImageIO Downsampling: Uses efficient CGImageSource for thumbnails without loading full images into memory
  • Memory Pressure Handling: Automatically evicts cache entries when iOS sends memory warnings
  • Lazy Gallery Rendering: Only renders current and adjacent items in slideshow view (not all 600+ items)

🎬 Video & Animation Improvements (v1.2.0)

  • WKWebView Video Player: Memory-efficient HTML5 video playback supporting WebM, MP4, and more
  • Native Animated Images: CGImageSource + display link rendering with LRU frame cache
  • sourceURL Property: Direct URL loading for animated images without intermediate decoding
  • Improved Gesture Support: Full zoom/pan support for animated images on macOS and iOS
  • Simplified Audio Controls: Mute/unmute toggle with persistent state between videos

🎡 Audio Support (v1.6.0)

  • Audio Media Type: New MediaType.audio for audio file support
  • Audio Player Controls: Full-featured playback with:
    • Play/pause button with elegant circular design
    • Scrubber slider for seeking with time display
    • Volume slider with expand/collapse animation
    • Mute/unmute toggle with persistent state
    • Progress tracking and duration display
  • Album Artwork Display: Shows embedded artwork or custom placeholder
  • Audio Placeholder Thumbnails: Gradient background with music note icon when no artwork exists
  • Audio Metadata: Title, artist, album, track number, and year support
  • Slideshow Integration: Audio files work seamlessly in slideshow with auto-advance

πŸ“² Background Playback & Local Caching (v1.7.0)

  • Local Media Caching: Download media files locally for offline/background playback
    • MediaDownloadManager: Singleton for managing downloads and cache
    • MediaDownloadButton: UI component with download/progress/cached states
    • Files stored in ~/Library/Caches/MediaStream/DownloadedMedia/ (unencrypted for AVPlayer)
  • Background Audio/Video Playback: Continue playback when app is backgrounded (cached media only)
  • Lock Screen & Control Center Integration:
    • Play/pause, next/previous track controls
    • Seek bar with accurate position tracking
    • Album artwork and metadata display (title, artist, album)
    • Playback position updates in real-time
  • Picture-in-Picture (PiP): Manual PiP toggle for cached videos
  • Smart Playback Behavior:
    • Short-form content (< 7 min): Starts from beginning (music behavior)
    • Long-form content (β‰₯ 7 min): Resumes from last position (podcast/movie behavior)
  • Cache Management:
    • Individual item download/clear in slideshow view
    • Bulk download/clear in grid view
    • Integrates with "Clear Cache" to remove downloaded media

🎞️ Native Animated Image Rendering & WebP Support (v1.9.0)

  • Native CGImageSource Rendering: Replaced WKWebView-based animated image display with native frame-by-frame rendering via CGImageSource + display link (CADisplayLink on iOS, Timer on macOS)
  • Animated WebP Support: Full frame duration extraction via kCGImagePropertyWebPDictionary across all animated image helpers
  • LRU Frame Cache: 4-frame sliding window cache for memory-efficient playback
  • Accurate Frame Timing: CACurrentMediaTime() for smooth animation without dropped or doubled frames
  • Improved macOS Gesture Support: Native NSView rendering eliminates WKWebView scroll event conflicts β€” zoom/pan gestures work correctly
  • Thumbnail Load Cancellation: Grid thumbnails cancel in-flight downloads when views disappear (e.g., gallery dismiss)
  • Media Type Re-filtering: Grid automatically re-checks filter chips when WebP/HEIC items resolve their actual animation state after download
  • Video Metadata Auth Headers: getVideoDurationWebView and hasAudioTrackWebView now pass auth headers through to the WebView fallback

🌐 VR & Stereoscopic 3D Support (v2.0.0)

  • 360/180 Spherical Video: Renders equirectangular video on an interactive SceneKit sphere with gyroscope and touch/drag controls
  • Stereoscopic Formats: Side-by-Side (SBS/HSBS) and Top-Bottom (TB/HTB) projection modes for 3D content
  • Fisheye Projection: Metal shader-based equidistant fisheye UV remapping for fisheye-encoded content (mono, SBS, TB)
  • 2D Flat Crop Mode: View stereoscopic content as flat 2D by cropping to one eye (left for SBS, top for TB) β€” works in both slideshow and grid views
  • Automatic Detection: Filename-based VR projection detection (e.g., _180_sbs, _360, _fisheye) via VRFilenameDetector
  • Manual Override: Per-item projection picker lets users manually set or change the VR projection type
  • Smart Thumbnail Cropping: Grid thumbnails automatically show only one eye for SBS/TB content
  • tvOS Support: Full VR projection controls, scrub bar, and slideshow overlay on Apple TV

πŸ“Ί tvOS Support (v2.0.0)

  • Apple TV Media Browser: Full-screen media viewer with native tvOS navigation and focus system
  • Slideshow Controls: Double-tap Play/Pause to access slideshow overlay with navigation, loop, shuffle, and interval controls
  • VR Projection Controls: SceneKit sphere rendering and flat crop modes on tvOS with confirmation dialog picker
  • Recently Played: Thumbnail cache with SBS/TB-aware cropping for recently played media
  • Native Video Controls: AVPlayerViewController integration with subtitle and audio track selection

πŸ“· RAW Image Support

  • Native RAW Support: Leverages iOS/macOS ImageIO for RAW image formats
  • Supported Formats: DNG, CR2, CR3, NEF, ARW, ORF, RW2, and other camera RAW formats
  • Efficient Thumbnails: Uses CGImageSource for memory-efficient RAW thumbnail generation
  • Full Resolution Display: RAW images display at full quality in slideshow view

πŸ”’ Sensitive Content Awareness (v2.7.0)

A complete, shared sensitive-content guard so every app embedding MediaStream blurs and gates flagged media identically β€” the host app injects its own age/verification policy and analyzer; MediaStream owns the presentation.

  • View-scoped reveal that resets on dismiss (v2.7.0): a per-item reveal or Reveal All is scoped to the gallery view instance and resets when the gallery is dismissed β€” reopening shows content blurred again. (Previously a reveal-all stayed revealed everywhere until force-quit.)
  • Re-guard on background β†’ foreground (v2.7.1): revealed content is re-blurred when the app is backgrounded while the gallery stays open (both grid and slideshow observe scenePhase and call resetReveals() on .background), so returning to the foreground shows sensitive media blurred again. Gated strictly on .background (not .inactive) to avoid re-blurring on a Control Center pull-down.
  • Single Done while fully blocked (v2.7.1): when the whole gallery is bulk-blocked, the navigation-toolbar trailing group (chrome Done + Download + Select) is suppressed so the block overlay's own Done is the only Done on screen β€” fixing duplicate overlapping Done buttons and preventing download/select of fully sensitive content.
  • Slideshow bulk block + share-leak gate (v2.7.2): the always-reachable bulk block now also covers the slideshow β€” a fully-sensitive gallery opened straight into the full-screen viewer (Ari / Enter Space) presents a persistent, never-auto-hidden Done plus an adult-gated Reveal All (previously the slideshow had no block, so the auto-hiding Close sat under the shield and the user was stuck with no Done β€” critical for minors). While the current item is sensitive and unrevealed, the slideshow's Share and per-item Download are hidden so unrevealed sensitive media can't be exfiltrated; they return after a verified-adult reveal. The grid and slideshow now share one SensitiveOverlayController.shouldBulkBlock(forKeys:totalCount:) gate so they can never disagree.
  • Persistent shielded nav + always-available Back-to-grid (v2.7.3): a never-auto-hidden top nav bar is now layered above the shield whenever the current slideshow item is shielded (per-item, not just bulk) β€” previously a single individually-shielded item covered the auto-hiding transport Close and left the user stuck with no way out. The bar offers a Back-to-grid arrow (when a grid exists) and a Dismiss xmark; the bulk case keeps its single sca.bulk.done. Separately, MediaGalleryFullView now always wires onBackToGrid, so a slideshow opened directly (Ari / Enter Space) shows the Back-to-grid arrow and returns to the thumbnails instead of plain-dismissing β€” the narrow anti-bounce behavior is preserved via onDismiss fully exiting on a direct entry, not by stripping navigation.
  • Demo app + automated XCUITest harness (v2.7.0): Example/ hosts a MediaStreamDemo iOS app (generated by XcodeGen from Example/project.yml) that drives the gallery against a stubbed SensitiveOverlayController whose age status and flag mode are settable via launch arguments. A full XCUITest suite runs headless on the iOS Simulator in CI and verifies every SCA + gallery path β€” per-item shield, bulk block, age gating (adult / undetermined / minor), slideshow reveal (no control collision), and reveal-resets-on-dismiss β€” so the SCA behavior no longer requires a physical device to verify. See Example/README.md.
  • Blur as an OVERLAY over the real image (v2.6.0): inject a SensitiveOverlayController via MediaGalleryConfiguration.sensitiveOverlay and the grid/slideshow draw the REAL image with a SwiftUI .blur overlay on flagged items β€” a verified adult's reveal removes the overlay and the sharp image shows instantly (no cache rebuild). Sensitive thumbnails are never written to the disk cache.
  • Generic, host-configurable copy (v2.6.0): SensitiveBlockCopy defaults to neutral "Sensitive Content" wording (no "conversation") for non-chat hosts; conversation hosts may override.
  • Smooth-blur interstitials (legacy bitmap path): SensitiveBlurRenderer renders a real gaussian-blurred bitmap of the flagged item with a "Sensitive Content" label, for surfaces that can only consume a finished bitmap.
  • Per-item + bulk blocking: .sensitiveContentShield(...) guards a single thumbnail; .sensitiveSurfaceBlock(...) covers a whole gallery or conversation with ONE interstitial when a meaningful share of items is sensitive (SensitiveBulkPolicy: β‰₯25% of items, or β‰₯3). A verified adult's single "Reveal All" then un-blurs the entire surface at once (revealAll()) β€” no per-item tapping.
  • Age-gated reveal (decision table): SensitiveShieldPresentation.decide(...) is the single source of truth β€” verified 18+ (or an active adult bypass) get "Show Anyway"; an undetermined-but-verifiable age gets "Verify Age to Reveal" (runs the system Declared Age Range request, then reveals on success); minors / unverifiable get the blur with no reveal affordance. Reveal state is session-only.
  • Fail-closed: SensitiveContentVerdict.analysisFailed keeps media shielded (and is never cached, so transient failures can be retried).
  • Full-screen cover with interactive chrome: sensitiveSurfaceBlock(..., topInteractivePassthrough:) paints full-bleed under the nav bar and input field while leaving the top navigation buttons tappable, so the user can navigate away themselves.
  • Host integration: conform your existing guard to SensitiveContentPolicy (age status, canReveal, canRequestVerificationFromShield, verdict(forKey:dataProvider:), revealAll(), requestAdultVerification(), anySensitive(in:)). Private/excluded media is never analyzed.

πŸ“¦ Installation

Swift Package Manager

Add the package to your Xcode project:

  1. In Xcode, go to File > Add Package Dependencies
  2. Enter the repository URL:
    https://github.com/blaineam/MediaStream.git
    
  3. Select your desired version or branch
  4. Click Add Package

Or add it to your Package.swift:

dependencies: [
    .package(url: "https://github.com/blaineam/MediaStream.git", from: "2.7.3")
]

Note: Pin a tagged release (e.g. 2.7.3) rather than branch: "main" so clean checkouts and CI resolve a reproducible version. See CHANGELOG.md for what each release contains.

πŸš€ Quick Start

Basic Slideshow

import SwiftUI
import MediaStream

struct ContentView: View {
    let mediaItems: [any MediaItem] = [
        // Your media items
    ]

    @State private var showGallery = false

    var body: some View {
        Button("Show Gallery") {
            showGallery = true
        }
        .sheet(isPresented: $showGallery) {
            MediaGalleryView(
                mediaItems: mediaItems,
                initialIndex: 0,
                onDismiss: {
                    showGallery = false
                }
            )
        }
    }
}

Grid View with Multi-Select

import SwiftUI
import MediaStream

struct GalleryBrowserView: View {
    let mediaItems: [any MediaItem]
    @State private var showGallery = false

    var body: some View {
        MediaGalleryGridView(
            mediaItems: mediaItems,
            multiSelectActions: [
                MediaGalleryMultiSelectAction(
                    title: "Delete",
                    icon: "trash"
                ) { selectedItems in
                    // Handle deletion
                    deleteItems(selectedItems)
                }
            ],
            includeBuiltInShareAction: true,
            onSelect: { index in
                // Open slideshow at selected index
                showGallery = true
            },
            onDismiss: {
                // Handle dismiss
            }
        )
    }
}

πŸ“– Implementation Guide

1. Implementing the MediaItem Protocol

The MediaItem protocol is the foundation of the package. Here's a complete implementation:

import Foundation
import MediaStream

#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif

struct PhotoMediaItem: MediaItem {
    let id: UUID
    let type: MediaType
    private let imageURL: URL
    private let caption: String?

    init(id: UUID = UUID(), imageURL: URL, caption: String? = nil, isAnimated: Bool = false) {
        self.id = id
        self.imageURL = imageURL
        self.caption = caption
        self.type = isAnimated ? .animatedImage : .image
    }

    // Load the image from disk or network
    func loadImage() async -> PlatformImage? {
        do {
            let data = try Data(contentsOf: imageURL)
            #if canImport(UIKit)
            return UIImage(data: data)
            #elseif canImport(AppKit)
            return NSImage(data: data)
            #endif
        } catch {
            print("Failed to load image: \(error)")
            return nil
        }
    }

    // Not used for images
    func loadVideoURL() async -> URL? {
        return nil
    }

    // Return duration for animated images
    func getAnimatedImageDuration() async -> TimeInterval? {
        guard type == .animatedImage else { return nil }
        return await AnimatedImageHelper.getAnimatedImageDuration(from: imageURL)
    }

    // Not used for images
    func getVideoDuration() async -> TimeInterval? {
        return nil
    }

    // Return the item to share (preserves original format)
    func getShareableItem() async -> Any? {
        return imageURL
    }

    // Return optional caption text
    func getCaption() async -> String? {
        return caption
    }

    // Videos only
    func hasAudioTrack() async -> Bool {
        return false
    }
}

2. Video Implementation

struct VideoMediaItem: MediaItem {
    let id: UUID
    let type: MediaType = .video
    private let videoURL: URL
    private let thumbnailURL: URL?

    init(id: UUID = UUID(), videoURL: URL, thumbnailURL: URL? = nil) {
        self.id = id
        self.videoURL = videoURL
        self.thumbnailURL = thumbnailURL
    }

    // Load thumbnail image for grid view
    func loadImage() async -> PlatformImage? {
        guard let thumbnailURL = thumbnailURL else { return nil }
        do {
            let data = try Data(contentsOf: thumbnailURL)
            #if canImport(UIKit)
            return UIImage(data: data)
            #elseif canImport(AppKit)
            return NSImage(data: data)
            #endif
        } catch {
            return nil
        }
    }

    // Return video URL for playback
    func loadVideoURL() async -> URL? {
        return videoURL
    }

    func getAnimatedImageDuration() async -> TimeInterval? {
        return nil
    }

    // Return video duration
    func getVideoDuration() async -> TimeInterval? {
        let asset = AVAsset(url: videoURL)
        return try? await asset.load(.duration).seconds
    }

    func getShareableItem() async -> Any? {
        return videoURL
    }

    func getCaption() async -> String? {
        return nil
    }

    // Check if video has audio track
    func hasAudioTrack() async -> Bool {
        let asset = AVAsset(url: videoURL)
        let tracks = try? await asset.loadTracks(withMediaType: .audio)
        return !(tracks?.isEmpty ?? true)
    }
}

3. Audio Implementation

struct AudioFileMediaItem: MediaItem {
    let id: UUID
    let type: MediaType = .audio
    private let audioURL: URL
    private let artworkURL: URL?
    private let metadata: AudioMetadata?

    init(id: UUID = UUID(), audioURL: URL, artworkURL: URL? = nil, metadata: AudioMetadata? = nil) {
        self.id = id
        self.audioURL = audioURL
        self.artworkURL = artworkURL
        self.metadata = metadata
    }

    // Load album artwork for grid view (returns placeholder if nil)
    func loadImage() async -> PlatformImage? {
        if let artworkURL = artworkURL {
            do {
                let data = try Data(contentsOf: artworkURL)
                #if canImport(UIKit)
                return UIImage(data: data)
                #elseif canImport(AppKit)
                return NSImage(data: data)
                #endif
            } catch {
                return nil
            }
        }
        // AudioMediaItem automatically returns audio placeholder when artwork is nil
        return nil
    }

    // Return audio URL for playback
    func loadAudioURL() async -> URL? {
        return audioURL
    }

    // Return audio duration
    func getAudioDuration() async -> TimeInterval? {
        let asset = AVAsset(url: audioURL)
        return try? await asset.load(.duration).seconds
    }

    // Return audio metadata for caption display
    func getAudioMetadata() async -> AudioMetadata? {
        return metadata
    }

    func loadVideoURL() async -> URL? { nil }
    func getAnimatedImageDuration() async -> TimeInterval? { nil }
    func getVideoDuration() async -> TimeInterval? { nil }
    func getShareableItem() async -> Any? { audioURL }
    func getCaption() async -> String? {
        guard let metadata = metadata else { return nil }
        var parts: [String] = []
        if let title = metadata.title { parts.append(title) }
        if let artist = metadata.artist { parts.append(artist) }
        if let album = metadata.album { parts.append(album) }
        return parts.isEmpty ? nil : parts.joined(separator: "\n")
    }
    func hasAudioTrack() async -> Bool { true }
}

4. Using Built-in AudioMediaItem

You can also use the built-in AudioMediaItem for simple audio playback:

let audioItem = AudioMediaItem(
    audioURLLoader: { return URL(fileURLWithPath: "/path/to/song.mp3") },
    artworkLoader: {
        // Load album artwork from ID3 tags or external source
        return await extractAlbumArt(from: audioURL)
    },
    durationLoader: { return 180.0 },
    metadataLoader: {
        return AudioMetadata(
            title: "Song Title",
            artist: "Artist Name",
            album: "Album Name",
            trackNumber: 1,
            year: 2024
        )
    }
)

5. Background Playback with Local Caching (v1.7.0)

import SwiftUI
import MediaStream

struct BackgroundPlaybackExample: View {
    let mediaItems: [any MediaItem]
    @ObservedObject private var downloadManager = MediaDownloadManager.shared

    var body: some View {
        VStack {
            // Download button for caching media locally
            MediaDownloadButton(
                mediaItems: mediaItems,
                headerProvider: { url in
                    // Return auth headers if needed for your media URLs
                    return ["Authorization": "Bearer \(token)"]
                }
            )

            // Check cache status
            if downloadManager.allCached(mediaItems) {
                Text("All media cached - background playback enabled!")
            }

            // Open gallery (background playback works automatically for cached items)
            MediaGalleryView(
                mediaItems: mediaItems,
                initialIndex: 0,
                onDismiss: { }
            )
        }
    }
}

Important Notes:

  • Background playback only works for cached/downloaded media items
  • Non-cached media will pause when the app enters background
  • The diskCacheKey property on MediaItem is required for caching
  • Lock screen controls (play/pause, next/prev, seek) work automatically
  • Album artwork and metadata display in Control Center when available

6. Configuring the Gallery

let config = MediaGalleryConfiguration(
    slideshowDuration: 5.0,        // Seconds per slide
    showControls: true,             // Show play/pause, share buttons
    backgroundColor: .black,        // Background color
    customActions: [                // Custom action buttons
        MediaGalleryAction(icon: "heart.fill") { index in
            print("Favorited item at index \(index)")
        },
        MediaGalleryAction(icon: "square.and.arrow.down") { index in
            print("Downloaded item at index \(index)")
        }
    ]
)

MediaGalleryView(
    mediaItems: mediaItems,
    initialIndex: 0,
    configuration: config,
    onDismiss: { }
)

5. Custom Filtering and Sorting

let filterConfig = MediaGalleryFilterConfig(
    customFilter: { item in
        // Only show images
        return item.type == .image
    },
    customSort: { item1, item2 in
        // Sort by type (images first, then videos)
        if item1.type == .image && item2.type != .image {
            return true
        }
        return false
    }
)

MediaGalleryGridView(
    mediaItems: mediaItems,
    filterConfig: filterConfig,
    onSelect: { index in },
    onDismiss: { }
)

6. Multi-Select Actions

let multiSelectActions = [
    MediaGalleryMultiSelectAction(
        title: "Export",
        icon: "square.and.arrow.down"
    ) { selectedItems in
        Task {
            for item in selectedItems {
                if let shareableItem = await item.getShareableItem() {
                    // Export the item
                    exportToFiles(shareableItem)
                }
            }
        }
    },
    MediaGalleryMultiSelectAction(
        title: "Delete",
        icon: "trash"
    ) { selectedItems in
        // Show confirmation
        showDeleteConfirmation(for: selectedItems)
    },
    MediaGalleryMultiSelectAction(
        title: "Add to Album",
        icon: "folder.badge.plus"
    ) { selectedItems in
        // Show album picker
        showAlbumPicker(for: selectedItems)
    }
]

MediaGalleryGridView(
    mediaItems: mediaItems,
    multiSelectActions: multiSelectActions,
    includeBuiltInShareAction: true,  // Adds built-in share button
    onSelect: { index in },
    onDismiss: { }
)

🎨 UI Components

Slideshow View

  • Navigation: Swipe left/right to navigate between items
  • Zoom: Double-tap to zoom in/out, pinch to zoom
  • Controls: Play/pause slideshow, share button, caption toggle
  • Caption: Collapsible caption overlay at bottom
  • Progress: Page indicator showing current position

Grid View

  • Responsive Layout: 3 columns (portrait) or 4 columns (landscape) on iOS
  • Filter Bar: Buttons to filter by media type
  • Multi-Select: Tap "Select" to enter multi-select mode
  • Selection Indicator: Blue checkmarks on selected items
  • Toolbar: Action buttons appear when items are selected

πŸ”§ Advanced Usage

Handling Encrypted/Private Images

struct EncryptedMediaItem: MediaItem {
    let id: UUID
    let type: MediaType
    private let encryptedURL: URL
    private let decryptionKey: Data

    func loadImage() async -> PlatformImage? {
        do {
            // Load encrypted data
            let encryptedData = try Data(contentsOf: encryptedURL)

            // Decrypt (using your encryption manager)
            let decryptedData = try decrypt(encryptedData, key: decryptionKey)

            #if canImport(UIKit)
            return UIImage(data: decryptedData)
            #elseif canImport(AppKit)
            return NSImage(data: decryptedData)
            #endif
        } catch {
            print("Failed to decrypt image: \(error)")
            return nil
        }
    }

    func getShareableItem() async -> Any? {
        // Create temporary decrypted file for sharing
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("\(UUID().uuidString).png")

        do {
            let encryptedData = try Data(contentsOf: encryptedURL)
            let decryptedData = try decrypt(encryptedData, key: decryptionKey)
            try decryptedData.write(to: tempURL)
            return tempURL
        } catch {
            return nil
        }
    }

    // ... other required methods
}

iCloud Download Support

The package includes built-in iCloud download support. When a file is not available locally, it will automatically attempt to download it from iCloud:

func loadImage() async -> PlatformImage? {
    let url = imageURL

    // Check if file exists, attempt iCloud download if needed
    if !FileManager.default.fileExists(atPath: url.path) {
        do {
            try FileManager.default.startDownloadingUbiquitousItem(at: url)

            // Wait for download (up to 5 seconds)
            for _ in 1...10 {
                if FileManager.default.fileExists(atPath: url.path) {
                    break
                }
                try await Task.sleep(nanoseconds: 500_000_000)
            }
        } catch {
            print("iCloud download failed: \(error)")
            return nil
        }
    }

    // Load the image
    // ...
}

πŸ“ Architecture

The package is designed with a protocol-oriented architecture:

MediaStream (Package)
β”œβ”€β”€ MediaItem (Protocol)
β”‚   β”œβ”€β”€ Defines interface for media items
β”‚   β”œβ”€β”€ Async methods for loading content
β”‚   β”œβ”€β”€ loadThumbnail for efficient thumbnail loading (v1.1.0)
β”‚   └── vrProjection for VR/3D content detection (v2.0.0)
β”œβ”€β”€ MediaGalleryView
β”‚   β”œβ”€β”€ Main slideshow view
β”‚   β”œβ”€β”€ Zoom & pan support
β”‚   β”œβ”€β”€ Slideshow controls
β”‚   β”œβ”€β”€ 3D/2D projection toggle
β”‚   └── Lazy rendering (only current + adjacent items)
β”œβ”€β”€ MediaGalleryGridView
β”‚   β”œβ”€β”€ Grid browsing interface
β”‚   β”œβ”€β”€ Multi-select mode
β”‚   β”œβ”€β”€ Filtering UI
β”‚   β”œβ”€β”€ SBS/TB thumbnail cropping
β”‚   └── LazyThumbnailView for visibility-based loading (v1.1.0)
β”œβ”€β”€ VRVideoPlayerView (v2.0.0)
β”‚   β”œβ”€β”€ SceneKit sphere rendering for 360/180 video
β”‚   β”œβ”€β”€ Gyroscope + drag navigation
β”‚   β”œβ”€β”€ Metal fisheye shader
β”‚   └── Projection picker overlay
β”œβ”€β”€ ThumbnailCache (v1.1.0)
β”‚   β”œβ”€β”€ LRU cache with memory limit
β”‚   β”œβ”€β”€ Memory pressure handling
β”‚   └── ImageIO-based downsampling
β”œβ”€β”€ ZoomableMediaView
β”‚   β”œβ”€β”€ Individual media display
β”‚   β”œβ”€β”€ Gesture handling
β”‚   └── Video playback
β”œβ”€β”€ ShareSheet
β”‚   β”œβ”€β”€ iOS: UIActivityViewController
β”‚   └── macOS: NSSharingServicePicker
└── AnimatedImageHelper
    β”œβ”€β”€ Format detection
    └── Duration calculation

🎯 API Reference

MediaItem Protocol

public protocol MediaItem: Identifiable, Sendable {
    var id: UUID { get }
    var type: MediaType { get }
    var diskCacheKey: String? { get }  // Optional disk caching
    var sourceURL: URL? { get }        // For animated image streaming

    func loadImage() async -> PlatformImage?
    func loadThumbnail(targetSize: CGFloat) async -> PlatformImage?
    func loadVideoURL() async -> URL?
    func loadAudioURL() async -> URL?              // v1.6.0
    func getAnimatedImageDuration() async -> TimeInterval?
    func getVideoDuration() async -> TimeInterval?
    func getAudioDuration() async -> TimeInterval? // v1.6.0
    func getAudioMetadata() async -> AudioMetadata? // v1.6.0
    func getShareableItem() async -> Any?
    func getCaption() async -> String?
    func hasAudioTrack() async -> Bool
}

AudioMetadata (v1.6.0)

public struct AudioMetadata: Sendable {
    public let title: String?
    public let artist: String?
    public let album: String?
    public let trackNumber: Int?
    public let year: Int?

    public init(
        title: String? = nil,
        artist: String? = nil,
        album: String? = nil,
        trackNumber: Int? = nil,
        year: Int? = nil
    )
}

ThumbnailCache (v1.1.0)

public final class ThumbnailCache {
    public static let shared: ThumbnailCache
    public static let thumbnailSize: CGFloat = 200

    public init(maxMemoryMB: Int = 100)

    public func get(_ id: UUID) -> PlatformImage?
    public func set(_ id: UUID, image: PlatformImage)
    public func contains(_ id: UUID) -> Bool
    public func clear()
    public func handleMemoryPressure()
    public var stats: (count: Int, memoryMB: Double)

    // Efficient thumbnail generation using ImageIO
    public static func createThumbnail(from image: PlatformImage, targetSize: CGFloat) -> PlatformImage
    public static func createThumbnail(from data: Data, targetSize: CGFloat) -> PlatformImage?
    public static func createThumbnail(from url: URL, targetSize: CGFloat) -> PlatformImage?
}

MediaDownloadManager (v1.7.0)

@MainActor
public final class MediaDownloadManager: ObservableObject {
    public static let shared: MediaDownloadManager

    // Published state
    @Published public private(set) var downloadState: DownloadState
    @Published public private(set) var progress: DownloadProgress?

    // Check cache status
    public func isCached(mediaItem: any MediaItem) -> Bool
    public func allCached(_ items: [any MediaItem]) -> Bool
    public func anyCached(_ items: [any MediaItem]) -> Bool
    public func cachedCount(of items: [any MediaItem]) -> Int
    public func canCache(_ mediaItem: any MediaItem) -> Bool

    // Get local file URL for cached media
    public func localURL(for mediaItem: any MediaItem) -> URL?

    // Download operations
    public func downloadAll(
        _ items: [any MediaItem],
        headerProvider: @escaping @Sendable (URL) async -> [String: String]?
    ) async
    public func cancelDownload()

    // Clear cache
    public func clearAllDownloads()
    public func clearDownloads(for items: [any MediaItem])

    // Cache statistics
    public var stats: (fileCount: Int, diskMB: Double)
}

public enum DownloadState: Equatable, Sendable {
    case idle
    case downloading(completed: Int, total: Int)
    case completed
    case cancelled
    case failed(String)
}

public struct DownloadProgress: Sendable {
    public let completed: Int
    public let total: Int
    public let currentItemName: String?
    public let bytesDownloaded: Int64
    public let totalBytes: Int64
    public var fractionCompleted: Double
    public var currentItemProgress: Double
}

MediaDownloadButton (v1.7.0)

/// A button that manages downloading and clearing cached media files.
/// Shows three states: not cached, downloading, cached.
public struct MediaDownloadButton: View {
    public init(
        mediaItems: [any MediaItem],
        headerProvider: @escaping @Sendable (URL) async -> [String: String]?
    )
}

// States:
// - Not cached: Download icon (arrow.down.circle)
// - Partially cached: Dotted download icon (arrow.down.circle.dotted)
// - Downloading: Progress ring with stop button
// - Cached: Green checkmark (checkmark.circle.fill)

MediaPlaybackService (v1.7.0)

@MainActor
public final class MediaPlaybackService: NSObject, ObservableObject {
    public static let shared: MediaPlaybackService

    // Notifications for external player integration
    public static let shouldPauseForBackgroundNotification: Notification.Name
    public static let externalPlayNotification: Notification.Name
    public static let externalPauseNotification: Notification.Name
    public static let externalSeekNotification: Notification.Name
    public static let externalTrackChangedNotification: Notification.Name

    // External playback mode (when views own the player)
    public var externalPlaybackMode: Bool

    // Playlist management
    public func setPlaylist(_ mediaItems: [any MediaItem], startIndex: Int = 0)
    public var currentIndex: Int
    public var loopMode: PlaybackLoopMode

    // Now Playing info for Control Center/Lock Screen
    public func updateNowPlayingForCurrentItem() async
    public func updateNowPlayingForExternalPlayer(
        mediaItem: any MediaItem,
        title: String?,
        artist: String?,
        album: String?,
        artwork: PlatformImage?,
        duration: TimeInterval,
        isVideo: Bool
    )
    public func updateExternalPlaybackPosition(
        currentTime: TimeInterval,
        duration: TimeInterval,
        isPlaying: Bool
    )

    // Picture-in-Picture (iOS only)
    public func setupPiP(with playerLayer: AVPlayerLayer)
    public func startPiP()
    public func stopPiP()
    public func togglePiP()
    public var isPiPActive: Bool
    public var isPiPPossible: Bool
}

public enum PlaybackLoopMode {
    case off    // Stop at end
    case all    // Loop entire playlist
    case one    // Repeat current track
}

MediaType Enum

public enum MediaType {
    case image
    case video
    case animatedImage
    case audio
}

MediaGalleryConfiguration

public struct MediaGalleryConfiguration {
    public var slideshowDuration: TimeInterval = 5.0
    public var showControls: Bool = true
    public var backgroundColor: Color = .black
    public var customActions: [MediaGalleryAction] = []
}

MediaGalleryAction

public struct MediaGalleryAction: Identifiable {
    public let id: UUID
    public let icon: String
    public let action: (Int) -> Void

    public init(id: UUID = UUID(), icon: String, action: @escaping (Int) -> Void)
}

MediaGalleryMultiSelectAction

public struct MediaGalleryMultiSelectAction: Identifiable {
    public let id: UUID
    public let title: String
    public let icon: String
    public let action: ([any MediaItem]) -> Void

    public init(
        id: UUID = UUID(),
        title: String,
        icon: String,
        action: @escaping ([any MediaItem]) -> Void
    )
}

MediaGalleryFilterConfig

public struct MediaGalleryFilterConfig {
    public var customFilter: ((any MediaItem) -> Bool)?
    public var customSort: ((any MediaItem, any MediaItem) -> Bool)?

    public init(
        customFilter: ((any MediaItem) -> Bool)? = nil,
        customSort: ((any MediaItem, any MediaItem) -> Bool)? = nil
    )
}

🎬 Animated Image Support

The package automatically detects and handles animated images:

  • GIF: Graphics Interchange Format
  • APNG: Animated PNG
  • HEIF: High Efficiency Image Format sequences
  • WebP: WebP animated images

Duration detection ensures animations play completely before advancing in slideshow mode.

πŸ–₯️ Platform Support

  • iOS: 17.0+
  • macOS: 14.0+
  • tvOS: 17.0+
  • Swift: 5.9+

Security

  • Comprehensive security audit completed April 2026
  • Content Security Policy hardening in WKWebView video player
  • Temp file permission hardening and automatic cleanup
  • HTML/JavaScript injection prevention
  • Canvas DoS mitigation

πŸ“ License

This project is licensed under the MIT License - see the LICENSE file for details.

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

πŸ“§ Contact

Blaine Miller - @blaineam

Project Link: https://github.com/blaineam/MediaStream


Made with ❀️ and SwiftUI

About

A simple and clean SwiftUI Media Gallery

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages