[mob][n] Cast app for tvOS (#7085)

## Description

## Tests
This commit is contained in:
Neeraj
2025-09-07 08:33:46 +05:30
committed by GitHub
124 changed files with 9288 additions and 0 deletions

6
mobile/native/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
**/.build/
.DS_Store
# Xcode user settings
xcuserdata/
.swiftpm/

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Back.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,14 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Middle.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "Back 1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Back 2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"images" : [
{
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "Middle 1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Middle 2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,32 @@
{
"assets" : [
{
"filename" : "App Icon - App Store.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "1280x768"
},
{
"filename" : "App Icon.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "400x240"
},
{
"filename" : "Top Shelf Image Wide.imageset",
"idiom" : "tv",
"role" : "top-shelf-image-wide",
"size" : "2320x720"
},
{
"filename" : "Top Shelf Image.imageset",
"idiom" : "tv",
"role" : "top-shelf-image",
"size" : "1920x720"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "Top Shelf Wide Image.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Top Shelf Wide Image@2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,30 @@
{
"images" : [
{
"filename" : "Top Shelf Image.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Top Shelf Image@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
},
{
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ente-photos-icon-transparent.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ducky.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "ducky_camera.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ducky_camera@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ducky_camera@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "ducky_tv.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ducky_tv@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ducky_tv@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = CastViewModel()
var body: some View {
ZStack {
switch viewModel.currentView {
case .connecting:
StatusView(
status: .loading(viewModel.statusMessage),
onRetry: nil,
debugLogs: nil
)
case .pairing:
PairingView(deviceCode: viewModel.deviceCode)
case .slideshow:
SlideshowView(
imageData: viewModel.currentImageData,
videoData: viewModel.currentVideoData,
isVideo: viewModel.currentFile?.isVideo ?? false,
slideshowService: viewModel.slideshowService
)
case .error:
StatusView(
status: .error(viewModel.errorMessage ?? "Unknown error"),
onRetry: viewModel.retryOperation,
debugLogs: nil
)
case .empty:
StatusView(
status: .empty("No photos were found in this album"),
onRetry: nil,
debugLogs: nil
)
}
}
.animation(.easeInOut(duration: 0.6), value: viewModel.currentView)
}
}
struct CinematicBackground: View {
var body: some View {
ZStack {
// Clean, sophisticated gradient like Spotify
LinearGradient(
colors: [
Color(red: 0.08, green: 0.08, blue: 0.08),
Color(red: 0.04, green: 0.04, blue: 0.04),
Color(red: 0.02, green: 0.02, blue: 0.02)
],
startPoint: .top,
endPoint: .bottom
)
// Subtle brand accent - strategic placement
RadialGradient(
colors: [
Color(red: 29/255, green: 185/255, blue: 84/255).opacity(0.08),
Color.clear
],
center: UnitPoint(x: 0.3, y: 0.2),
startRadius: 200,
endRadius: 800
)
}
.ignoresSafeArea()
}
}
#Preview {
ContentView()
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIAppFonts</key>
<array>
<string>Gilroy-Black.ttf</string>
<string>Gilroy-Extrabold.ttf</string>
<string>Inter-Regular.ttf</string>
<string>Inter-Medium.ttf</string>
<string>Inter-SemiBold.ttf</string>
<string>Inter-Bold.ttf</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,166 @@
//
// CastModels.swift
// tv
//
// Created by Neeraj Gupta on 28/08/25.
//
import Foundation
import AVKit
// MARK: - Notification Extensions
extension Notification.Name {
static let authenticationExpired = Notification.Name("authenticationExpired")
static let slideshowRestarted = Notification.Name("slideshowRestarted")
}
// MARK: - Slide Configuration
struct SlideConfiguration {
let imageDuration: TimeInterval
let videoDuration: TimeInterval
let useThumbnails: Bool
let shuffle: Bool
let maxImageSize: Int64
let maxVideoSize: Int64
let includeVideos: Bool
// Enhanced prefetch settings
let prefetchCount: Int
let maxCacheSize: Int
let prefetchDelay: TimeInterval
let enablePrefetching: Bool
init(
imageDuration: TimeInterval = 12.0,
videoDuration: TimeInterval = 30.0,
useThumbnails: Bool = false,
shuffle: Bool = true,
maxImageSize: Int64 = 100 * 1024 * 1024, // 100MB
maxVideoSize: Int64 = 500 * 1024 * 1024, // 500MB
includeVideos: Bool = true,
prefetchCount: Int = 3,
maxCacheSize: Int = 5,
prefetchDelay: TimeInterval = 0.5,
enablePrefetching: Bool = true
) {
self.imageDuration = imageDuration
self.videoDuration = videoDuration
self.useThumbnails = useThumbnails
self.shuffle = shuffle
self.maxImageSize = maxImageSize
self.maxVideoSize = maxVideoSize
self.includeVideos = includeVideos
self.prefetchCount = prefetchCount
self.maxCacheSize = maxCacheSize
self.prefetchDelay = prefetchDelay
self.enablePrefetching = enablePrefetching
}
func duration(for file: CastFile) -> TimeInterval {
return file.isVideo ? videoDuration : imageDuration
}
static let `default` = SlideConfiguration()
// TV-optimized configuration (use thumbnails for better performance)
static let tvOptimized = SlideConfiguration(
imageDuration: 8.0, // Faster progression for TV viewing
videoDuration: 25.0,
useThumbnails: true, // Better performance on Apple TV
shuffle: true,
maxImageSize: 50 * 1024 * 1024, // 50MB for TV
maxVideoSize: 200 * 1024 * 1024, // 200MB for TV
includeVideos: true,
prefetchCount: 3, // Prefetch next 3 slides
maxCacheSize: 5, // Keep 5 slides in cache
prefetchDelay: 0.5, // 500ms delay between prefetch operations
enablePrefetching: true // Enable prefetching for smooth transitions
)
}
// MARK: - Data Models
struct CastFile: Codable, Equatable {
let id: Int
let title: String
let isVideo: Bool
let isLivePhoto: Bool
let encryptedKey: String
let keyDecryptionNonce: String
let fileDecryptionHeader: String
let hash: String? // BLAKE2b hash for file content verification
var isImage: Bool { !isVideo && !isLivePhoto }
}
struct FileMetadata {
let fileType: Int // 0 = image, 1 = video, 2 = livePhoto
let title: String // filename with extension
let creationTime: Int64 // microseconds since epoch
let modificationTime: Int64
let hash: String? // BLAKE2b hash for file content verification
var isImage: Bool { fileType == 0 }
var isVideo: Bool { fileType == 1 }
var isLivePhoto: Bool { fileType == 2 }
}
enum CastSessionState: Equatable {
case idle
case registering
case waitingForPairing(deviceCode: String)
case connected(CastPayload)
case error(String)
}
struct CastPayload: Codable, Equatable {
let collectionID: Int
let collectionKey: String
let castToken: String
}
struct CastDevice {
let deviceCode: String
let publicKey: Data
let privateKey: Data
}
// MARK: - Server Response Models
struct DeviceRegistrationResponse: Codable {
let deviceCode: String
}
struct CastDataResponse: Codable {
let encCastData: String?
}
// MARK: - Errors
enum CastError: Error {
case networkError(String)
case serverError(Int, String?)
case decryptionError(String)
var localizedDescription: String {
switch self {
case .networkError(let message):
return "Network error: \(message)"
case .serverError(let code, let message):
return "Server error \(code): \(message ?? "Unknown error")"
case .decryptionError(let message):
return "Decryption error: \(message)"
}
}
}
// MARK: - Live Photo Models
struct LivePhotoComponents {
let imageData: Data?
let videoData: Data?
let imagePath: URL?
let videoPath: URL?
}
// MARK: - Cache Metadata
struct CacheMetadata: Codable {
let fileIDs: [Int]
let totalBytes: Int
}

View File

@@ -0,0 +1,252 @@
//
// CastPairingService.swift
// tv
//
// Created by Neeraj Gupta on 28/08/25.
//
import SwiftUI
import CryptoKit
import Foundation
import EnteCrypto
// MARK: - Cast Session Management
@MainActor
class CastSession: ObservableObject {
@Published var state: CastSessionState = .idle
@Published var isActive: Bool = false
var deviceCode: String? {
if case .waitingForPairing(let code) = state {
return code
}
return nil
}
var payload: CastPayload? {
if case .connected(let payload) = state {
return payload
}
return nil
}
func setState(_ newState: CastSessionState) {
state = newState
isActive = !isIdle
}
private var isIdle: Bool {
if case .idle = state {
return true
}
return false
}
}
// MARK: - Real Cast Pairing Service
class RealCastPairingService {
private let baseURL = "https://api.ente.io"
private var pollingTimer: Timer?
private var lastLoggedMessages: [String: Date] = [:]
private let logThrottleInterval: TimeInterval = 5.0 // 5 seconds
private var isPolling: Bool = false
private var isFetchingPayload: Bool = false
private var hasDeliveredPayload: Bool = false
private var pollingStartTime: Date?
private let initialPollingInterval: TimeInterval = 2.0 // 2 seconds initially
private let extendedPollingInterval: TimeInterval = 5.0 // 5 seconds after 60 seconds
private let pollingIntervalSwitchTime: TimeInterval = 60.0 // Switch after 60 seconds
private var hasLoggedIntervalSwitch: Bool = false
private func getCurrentPollingInterval() -> TimeInterval {
guard let startTime = pollingStartTime else { return initialPollingInterval }
let elapsed = Date().timeIntervalSince(startTime)
let newInterval = elapsed >= pollingIntervalSwitchTime ? extendedPollingInterval : initialPollingInterval
// Log when switching to extended interval for the first time
if elapsed >= pollingIntervalSwitchTime && newInterval == extendedPollingInterval && !hasLoggedIntervalSwitch {
print("🕐 Switched to extended polling interval (\(extendedPollingInterval)s) after \(Int(elapsed))s")
hasLoggedIntervalSwitch = true
}
return newInterval
}
// Generate real X25519 keypair using EnteCrypto
private func generateKeyPair() -> (publicKey: Data, privateKey: Data) {
let keys = EnteCrypto.generateCastKeyPair()
return (
publicKey: Data(base64Encoded: keys.publicKey)!,
privateKey: Data(base64Encoded: keys.privateKey)!
)
}
func registerDevice() async throws -> CastDevice {
print("🔑 Generating real X25519 keypair...")
let keys = generateKeyPair()
let publicKeyBase64 = keys.publicKey.base64EncodedString()
print("📡 Registering device with Ente production server...")
print("🌐 POST \(baseURL)/cast/device-info")
let url = URL(string: "\(baseURL)/cast/device-info")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody = ["publicKey": publicKeyBase64]
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw CastError.networkError("Invalid response")
}
guard httpResponse.statusCode == 200 else {
throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8))
}
let deviceResponse = try JSONDecoder().decode(DeviceRegistrationResponse.self, from: data)
print("✅ Device registered! Code from server: \(deviceResponse.deviceCode)")
return CastDevice(
deviceCode: deviceResponse.deviceCode,
publicKey: keys.publicKey,
privateKey: keys.privateKey
)
}
func startPolling(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) {
guard !hasDeliveredPayload else { return }
guard !isPolling else { return }
print("🔍 Starting real polling of Ente production server...")
pollingTimer?.invalidate()
isPolling = true
pollingStartTime = Date()
hasLoggedIntervalSwitch = false
scheduleNextPoll(device: device, onPayloadReceived: onPayloadReceived, onError: onError)
}
private func scheduleNextPoll(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) {
guard isPolling && !hasDeliveredPayload else { return }
let currentInterval = getCurrentPollingInterval()
pollingTimer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: false) { [weak self] _ in
Task {
await self?.checkForPayload(device: device, onPayloadReceived: onPayloadReceived, onError: onError)
// Schedule next poll after this one completes
await MainActor.run {
self?.scheduleNextPoll(device: device, onPayloadReceived: onPayloadReceived, onError: onError)
}
}
}
}
private func checkForPayload(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) async {
if hasDeliveredPayload { return }
if isFetchingPayload { return }
isFetchingPayload = true
defer { isFetchingPayload = false }
do {
let url = URL(string: "\(baseURL)/cast/cast-data/\(device.deviceCode)")!
print("📡 GET \(url.absoluteString)")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw CastError.networkError("Invalid response")
}
if httpResponse.statusCode == 404 {
// No payload available yet - this is expected
print("⏳ No encrypted payload available yet")
return
}
guard httpResponse.statusCode == 200 else {
throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8))
}
let castDataResponse = try JSONDecoder().decode(CastDataResponse.self, from: data)
guard let encryptedData = castDataResponse.encCastData else {
print("⏳ No encrypted payload in response yet")
return
}
print("🔓 Received encrypted payload! Decrypting...")
// Decrypt the payload using our private key
let payload = try await decryptPayload(encryptedData: encryptedData, privateKey: device.privateKey)
print("✅ Successfully decrypted cast payload!")
hasDeliveredPayload = true
stopPolling()
await MainActor.run {
onPayloadReceived(payload)
}
} catch {
print("❌ Polling error: \(error)")
await MainActor.run {
onError(error)
}
}
}
private func decryptPayload(encryptedData: String, privateKey: Data) async throws -> CastPayload {
// Generate public key from private key for sealed box decryption
let publicKey: Data
do {
let curve25519PrivateKey = try CryptoKit.Curve25519.KeyAgreement.PrivateKey(rawRepresentation: privateKey)
publicKey = curve25519PrivateKey.publicKey.rawRepresentation
} catch {
throw CastError.decryptionError("Failed to derive public key from private key: \(error)")
}
// Use EnteCrypto for cast payload decryption
do {
let decryptedData = try EnteCrypto.decryptCastPayload(
encryptedPayload: encryptedData,
recipientPublicKey: publicKey.base64EncodedString(),
recipientPrivateKey: privateKey.base64EncodedString()
)
// Handle potential base64 preprocessing from mobile client
let finalData: Data
if let base64String = String(data: decryptedData, encoding: .utf8),
let jsonData = Data(base64Encoded: base64String) {
finalData = jsonData
} else {
finalData = decryptedData
}
let payload = try JSONDecoder().decode(CastPayload.self, from: finalData)
return payload
} catch {
throw CastError.decryptionError("EnteCrypto cast payload decryption failed: \(error)")
}
}
func stopPolling() {
guard isPolling else { return }
pollingTimer?.invalidate()
pollingTimer = nil
isPolling = false
pollingStartTime = nil
print("⏹️ Stopped polling production server")
}
func resetForNewSession() {
print("🔄 Resetting pairing service for new session")
stopPolling()
hasDeliveredPayload = false
hasLoggedIntervalSwitch = false
}
}

View File

@@ -0,0 +1,189 @@
//
// FileCache.swift
// tv
//
// Created by Neeraj Gupta on 28/08/25.
//
import Foundation
// MARK: - Persistent Thread-Safe File Cache
actor ThreadSafeFileCache {
private var cache: [Int: Data] = [:]
private var cacheOrder: [Int] = []
private var totalBytes: Int = 0
private let maxBytes: Int
private let shrinkTargetBytes: Int
private let cacheDirectory: URL
private let metadataURL: URL
init(maxBytes: Int, shrinkTargetBytes: Int) {
self.maxBytes = maxBytes
self.shrinkTargetBytes = shrinkTargetBytes
// Create persistent cache directory
let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
self.cacheDirectory = documentsPath.appendingPathComponent("EnteFileCache")
self.metadataURL = cacheDirectory.appendingPathComponent("cache_metadata.json")
// Create cache directory if it doesn't exist
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
// Load existing cache from disk
loadCacheFromDisk()
}
func get(_ fileID: Int) -> Data? {
// Check memory cache first
if let data = cache[fileID] {
return data
}
// Check disk cache
let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache")
if let data = try? Data(contentsOf: fileURL) {
// Load into memory cache for faster future access
cache[fileID] = data
if !cacheOrder.contains(fileID) {
cacheOrder.append(fileID)
}
totalBytes += data.count
return data
}
return nil
}
func set(_ fileID: Int, data: Data) {
// Remove existing data if present
if let existingData = cache[fileID] {
totalBytes -= existingData.count
cacheOrder.removeAll { $0 == fileID }
}
// Add new data to memory cache
cache[fileID] = data
cacheOrder.append(fileID)
totalBytes += data.count
// Save to disk cache
let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache")
try? data.write(to: fileURL)
// Enforce limits
enforceLimits()
// Save metadata
saveCacheMetadata()
print("💾 Cached file \(fileID) content (\(data.count) bytes) - Cache size: \(cache.count) files")
}
func remove(_ fileID: Int) {
if let removedData = cache.removeValue(forKey: fileID) {
totalBytes -= removedData.count
cacheOrder.removeAll { $0 == fileID }
// Remove from disk cache
let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache")
try? FileManager.default.removeItem(at: fileURL)
// Save updated metadata
saveCacheMetadata()
print("🗑️ Removed cached content for file \(fileID) (\(removedData.count) bytes)")
}
}
func clear() {
let clearedCount = cache.count
cache.removeAll()
cacheOrder.removeAll()
totalBytes = 0
// Clear disk cache
try? FileManager.default.removeItem(at: cacheDirectory)
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
// Clear metadata
try? FileManager.default.removeItem(at: metadataURL)
print("🗑️ Cleared file content cache (\(clearedCount) files)")
}
func getStats() -> (count: Int, totalSize: Int) {
return (count: cache.count, totalSize: totalBytes)
}
func getCachedFileIDs() -> [Int] {
return Array(cache.keys) + cacheOrder.filter { !cache.keys.contains($0) }
}
private func enforceLimits() {
guard totalBytes > maxBytes else { return }
var removedBytes = 0
while totalBytes - removedBytes > shrinkTargetBytes, let oldest = cacheOrder.first {
cacheOrder.removeFirst()
if let data = cache.removeValue(forKey: oldest) {
removedBytes += data.count
// Remove from disk cache
let fileURL = cacheDirectory.appendingPathComponent("\(oldest).cache")
try? FileManager.default.removeItem(at: fileURL)
print("🧹 Evicted file \(oldest) (\(data.count) bytes) to control cache size")
}
}
totalBytes -= removedBytes
// Save updated metadata after eviction
saveCacheMetadata()
print("📦 Cache GC complete: now \(cache.count) files, \(totalBytes) bytes")
}
private func loadCacheFromDisk() {
// Load metadata
guard let metadataData = try? Data(contentsOf: metadataURL),
let metadata = try? JSONDecoder().decode(CacheMetadata.self, from: metadataData) else {
print("📂 No existing cache metadata found - starting fresh")
return
}
print("📂 Loading existing cache from disk - \(metadata.fileIDs.count) files")
// Load cache order and calculate total bytes
var loadedBytes = 0
var validFileIDs: [Int] = []
for fileID in metadata.fileIDs {
let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache")
if FileManager.default.fileExists(atPath: fileURL.path) {
if let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let fileSize = attributes[.size] as? Int {
loadedBytes += fileSize
validFileIDs.append(fileID)
}
}
}
cacheOrder = validFileIDs
totalBytes = loadedBytes
print("📂 Loaded \(validFileIDs.count) cached files (\(loadedBytes) bytes) from disk")
// Clean up any invalid entries
if validFileIDs.count != metadata.fileIDs.count {
saveCacheMetadata()
}
}
private func saveCacheMetadata() {
let metadata = CacheMetadata(fileIDs: cacheOrder, totalBytes: totalBytes)
if let data = try? JSONEncoder().encode(metadata) {
try? data.write(to: metadataURL)
}
}
}

View File

@@ -0,0 +1,54 @@
//
// FontUtils.swift
// tv
//
// Created on 28/08/25.
//
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct FontUtils {
// MARK: - Safe Font Creation
private static func safeFont(name: String, size: CGFloat, fallback: Font) -> Font {
#if canImport(UIKit)
if UIFont(name: name, size: size) != nil {
return .custom(name, size: size)
} else {
return fallback
}
#else
return .custom(name, size: size)
#endif
}
// MARK: - Font Presets
static func gilroyBlack(size: CGFloat) -> Font {
return safeFont(name: "Gilroy-Black", size: size, fallback: .system(size: size, weight: .black))
}
static func gilroyExtraBold(size: CGFloat) -> Font {
return safeFont(name: "Gilroy-Extrabold", size: size, fallback: .system(size: size, weight: .heavy))
}
static func interRegular(size: CGFloat) -> Font {
return safeFont(name: "Inter-Regular", size: size, fallback: .system(size: size, weight: .regular))
}
static func interMedium(size: CGFloat) -> Font {
return safeFont(name: "Inter-Medium", size: size, fallback: .system(size: size, weight: .medium))
}
static func interSemiBold(size: CGFloat) -> Font {
return safeFont(name: "Inter-SemiBold", size: size, fallback: .system(size: size, weight: .semibold))
}
static func interBold(size: CGFloat) -> Font {
return safeFont(name: "Inter-Bold", size: size, fallback: .system(size: size, weight: .bold))
}
}

View File

@@ -0,0 +1,68 @@
//
// ScreenSaverManager.swift
// tv
//
// Created by Claude on 03/09/25.
//
import Foundation
import UIKit
#if canImport(UIKit)
import UIKit
#endif
@MainActor
class ScreenSaverManager: ObservableObject {
private static let shared = ScreenSaverManager()
private var isDisabled = false
private var refreshTimer: Timer?
static func preventScreenSaver() {
shared.startPrevention()
}
static func allowScreenSaver() {
shared.stopPrevention()
}
private func startPrevention() {
guard !isDisabled else {
print("🚫 Screen saver prevention already enabled")
return
}
#if os(tvOS)
UIApplication.shared.isIdleTimerDisabled = true
isDisabled = true
print("🚫 Screen saver prevention enabled")
// Fallback for problematic tvOS versions where isIdleTimerDisabled doesn't work reliably
// This timer periodically refreshes the setting to ensure it stays disabled
refreshTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
UIApplication.shared.isIdleTimerDisabled = false
UIApplication.shared.isIdleTimerDisabled = true
}
#endif
}
private func stopPrevention() {
guard isDisabled else {
print("✅ Screen saver prevention already disabled")
return
}
#if os(tvOS)
refreshTimer?.invalidate()
refreshTimer = nil
UIApplication.shared.isIdleTimerDisabled = false
isDisabled = false
print("✅ Screen saver prevention disabled")
#endif
}
// Cleanup method for app termination
static func cleanup() {
shared.stopPrevention()
}
}

View File

@@ -0,0 +1,398 @@
//
// CastViewModel.swift
// tv
//
// Created by Neeraj Gupta on 28/08/25.
//
import SwiftUI
import Combine
#if canImport(UIKit)
import UIKit
#endif
@MainActor
class CastViewModel: ObservableObject {
// MARK: - Published Properties
@Published var currentView: CurrentView = .connecting
@Published var deviceCode: String = ""
@Published var currentImageData: Data?
@Published var currentVideoData: Data?
@Published var currentFile: CastFile?
@Published var statusMessage: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let pairingService: RealCastPairingService
private var lastLoggedMessages: [String: Date] = [:]
private let logThrottleInterval: TimeInterval = 3.0 // 3 seconds
private let castSession: CastSession
// MARK: - Public Properties
public let slideshowService: RealSlideshowService
enum CurrentView {
case pairing
case connecting
case slideshow
case error
case empty
}
init() {
print("🚀 Initializing with REAL Ente production server calls!")
self.castSession = CastSession()
self.pairingService = RealCastPairingService()
self.slideshowService = RealSlideshowService()
setupBindings()
// Auto-start cast session on app launch (like web implementation)
startCastSession()
}
private func setupBindings() {
// Bind to cast session state changes
castSession.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
self?.handleStateChange(state)
}
.store(in: &cancellables)
// Bind to slideshow service updates
slideshowService.$currentImageData
.receive(on: DispatchQueue.main)
.assign(to: \.currentImageData, on: self)
.store(in: &cancellables)
// Auto-transition once first image arrives (covers single-image albums and empty-to-populated)
slideshowService.$currentImageData
.receive(on: DispatchQueue.main)
.sink { [weak self] data in
guard let self = self else { return }
if data != nil && (self.currentView == .connecting || self.currentView == .empty) {
self.currentView = .slideshow
self.statusMessage = ""
self.isLoading = false
self.errorMessage = nil
}
}
.store(in: &cancellables)
slideshowService.$currentVideoData
.receive(on: DispatchQueue.main)
.assign(to: \.currentVideoData, on: self)
.store(in: &cancellables)
slideshowService.$currentVideoData
.receive(on: DispatchQueue.main)
.sink { [weak self] data in
guard let self = self else { return }
if data != nil && (self.currentView == .connecting || self.currentView == .empty) {
self.currentView = .slideshow
self.statusMessage = ""
self.isLoading = false
self.errorMessage = nil
}
}
.store(in: &cancellables)
slideshowService.$currentFile
.receive(on: DispatchQueue.main)
.assign(to: \.currentFile, on: self)
.store(in: &cancellables)
slideshowService.$error
.receive(on: DispatchQueue.main)
.compactMap { $0 }
.sink { [weak self] error in
self?.handleSlideshowError(error)
}
.store(in: &cancellables)
// Listen for authentication expired notifications
NotificationCenter.default.publisher(for: .authenticationExpired)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleAuthenticationExpired()
}
.store(in: &cancellables)
// Listen for slideshow restarted notifications
NotificationCenter.default.publisher(for: .slideshowRestarted)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleSlideshowRestarted()
}
.store(in: &cancellables)
// Listen for app lifecycle events to manage screen saver prevention
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
.receive(on: DispatchQueue.main)
.sink { _ in
ScreenSaverManager.allowScreenSaver()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if self?.currentView == .slideshow {
ScreenSaverManager.preventScreenSaver()
}
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func startCastSession() {
castSession.setState(.registering)
currentView = .connecting
isLoading = true
statusMessage = "Registering device..."
Task {
do {
// Register device with the server
let device = try await pairingService.registerDevice()
await MainActor.run {
deviceCode = device.deviceCode
castSession.setState(.waitingForPairing(deviceCode: device.deviceCode))
currentView = .pairing
statusMessage = "Waiting for connection..."
isLoading = false
}
// Start polling for payload
pairingService.startPolling(
device: device,
onPayloadReceived: { [weak self] payload in
Task { @MainActor in
self?.handlePayloadReceived(payload)
}
},
onError: { [weak self] error in
Task { @MainActor in
self?.handleNetworkError(error)
}
}
)
} catch {
handleNetworkError(error)
}
}
}
func resetSession() async {
print("🔄 Resetting cast session...")
// Ensure screen saver prevention is disabled during reset
ScreenSaverManager.allowScreenSaver()
currentView = .connecting
deviceCode = ""
currentImageData = nil
currentVideoData = nil
currentFile = nil
statusMessage = ""
isLoading = false
errorMessage = nil
// Clean up services
pairingService.stopPolling()
await slideshowService.stop()
castSession.setState(.idle)
print("✅ Cast session reset complete")
}
private func handlePayloadReceived(_ payload: CastPayload) {
// Idempotency: if we're already connected with same payload, skip
if case .connected(let existing) = castSession.state, existing == payload {
return
}
print("🎉 Cast payload received successfully!")
// Update cast session with the payload
castSession.setState(.connected(payload))
currentView = .connecting
statusMessage = ""
isLoading = true
// Stop polling once we receive payload
// pairingService.stopPolling() // redundant; pairing service already stops itself when delivering payload
// Start the slideshow with the full payload
Task {
// Add a small delay to prevent flickering from rapid state changes
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
await slideshowService.start(castPayload: payload)
// Additional delay to ensure slideshow is properly loaded
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
await MainActor.run {
let hasError = slideshowService.error != nil && !slideshowService.error!.isEmpty
if hasError {
handleSlideshowError(slideshowService.error!)
} else {
currentView = .slideshow
statusMessage = ""
isLoading = false
}
}
}
}
func retryOperation() {
errorMessage = nil
switch currentView {
case .error:
startCastSession()
default:
break
}
}
// MARK: - Slideshow Navigation
func nextSlide() {
guard currentView == .slideshow else { return }
Task {
await slideshowService.nextSlide()
}
}
func previousSlide() {
guard currentView == .slideshow else { return }
Task {
await slideshowService.previousSlide()
}
}
// MARK: - Private Methods
private func handleStateChange(_ state: CastSessionState) {
switch state {
case .idle:
currentView = .connecting
case .registering:
currentView = .connecting
statusMessage = "Registering device..."
isLoading = true
case .waitingForPairing(let code):
deviceCode = code
currentView = .pairing
statusMessage = "Waiting for connection..."
isLoading = false
case .connected(let payload):
handlePayloadReceived(payload)
case .error(let message):
handleError(message)
}
}
private func handleSlideshowError(_ error: String) {
// Prevent stale slideshow errors from a previous (expired) session
// from overriding the fresh pairing code UI. Only react to slideshow
// errors once we are actually in a connected/slideshow flow.
// Exception: Allow empty state errors even during connecting
let isEmptyStateError = error.contains("No media files") ||
error.contains("available in this album") ||
error.contains("available in this collection") ||
error.contains("Empty file list")
if (currentView == .pairing || currentView == .connecting) && !isEmptyStateError {
// Ignore slideshow-originating errors during pairing / reconnection, except empty state
return
}
if isEmptyStateError {
currentView = .empty
statusMessage = ""
isLoading = false
} else {
handleError(error)
}
}
private func handleError(_ message: String) {
print("❌ Cast Error: \(message)")
currentView = .error
errorMessage = message
isLoading = false
statusMessage = ""
castSession.setState(.error(message))
}
private func handleNetworkError(_ error: Error) {
// Show error immediately
handleError("An error occurred: \(error.localizedDescription)")
// Wait 5 seconds then reset state for new device registration
Task {
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
await MainActor.run {
// Reset to start new device registration
startCastSession()
}
}
}
private func handleAuthenticationExpired() {
print("🔐 Authentication expired notification received - clearing all state and restarting")
Task {
await resetSession()
// Clear all in-memory token state in slideshow service
await slideshowService.clearExpiredTokenState()
// Reset pairing service to allow fresh polling
pairingService.resetForNewSession()
// Ensure we're in a clean state before starting new session
await MainActor.run {
// Directly set to pairing once device registration begins to avoid brief empty/error flicker
currentView = .connecting
errorMessage = nil
statusMessage = "Starting fresh session..."
isLoading = true
}
// Generate fresh device code for new pairing
startCastSession()
}
}
private func handleSlideshowRestarted() {
print("🎬 Slideshow restarted notification received")
// Wait for image data to be available before transitioning
// The binding will handle the transition when data arrives
statusMessage = ""
isLoading = false
errorMessage = nil
// Only transition if we already have image data
if slideshowService.currentImageData != nil || slideshowService.currentVideoData != nil {
print("🎬 Image/video data available - transitioning to slideshow view")
currentView = .slideshow
} else {
print("⏳ Waiting for image data before transitioning to slideshow view")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct EnteBranding: View {
var body: some View {
VStack(alignment: .leading, spacing: -4) {
Text("ente")
.font(FontUtils.gilroyExtraBold(size: 40))
.foregroundColor(.black)
Text("photos")
.font(FontUtils.gilroyBlack(size: 20))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(red: 0/255, green: 179/255, blue: 61/255))
)
.rotationEffect(.degrees(-8))
.offset(x: 20, y: -2)
}
}
}
#Preview {
ZStack {
Color.white
EnteBranding()
}
}

View File

@@ -0,0 +1,182 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct PairingView: View {
let deviceCode: String
@State private var pulseScale: CGFloat = 1.0
var body: some View {
GeometryReader { geometry in
ZStack {
// White background
Color.white
.ignoresSafeArea()
// Main content - card positioned with custom padding
HStack(spacing: 0) {
// Left padding (1x)
Spacer()
.frame(width: geometry.size.width * 0.05)
// Green card with content
ZStack {
// Green card background
RoundedRectangle(cornerRadius: 36)
.fill(Color(red: 0/255, green: 179/255, blue: 61/255))
.shadow(color: Color.black.opacity(0.25), radius: 4, y: 4)
// Card content
VStack(spacing: 0) {
// Title section
VStack(spacing: -5) {
Text("Ready to")
.font(FontUtils.gilroyExtraBold(size: geometry.size.width * 0.035))
.foregroundColor(.white)
Text("Connect?")
.font(FontUtils.gilroyExtraBold(size: geometry.size.width * 0.065))
.foregroundColor(.white)
}
.padding(.top, geometry.size.height * 0.06)
Spacer()
.frame(height: geometry.size.height * 0.05)
// Pairing code box
Text(deviceCode)
.font(.system(size: geometry.size.width * 0.09, weight: .heavy, design: .monospaced))
.tracking(geometry.size.width * 0.015)
.foregroundColor(.white)
.scaleEffect(pulseScale)
.padding(.horizontal, geometry.size.width * 0.04)
.padding(.vertical, geometry.size.height * 0.025)
.background(
RoundedRectangle(cornerRadius: 32)
.fill(Color(red: 0/255, green: 150/255, blue: 51/255))
)
Spacer()
.frame(height: geometry.size.height * 0.08)
// Instruction steps
HStack(spacing: geometry.size.width * 0.05) {
InstructionStep(
number: "1",
text: "Open any album",
icon: "photo.on.rectangle.angled",
geometry: geometry
)
InstructionStep(
number: "2",
text: "Click Cast button",
icon: "tv",
geometry: geometry
)
InstructionStep(
number: "3",
text: "Enter the code",
icon: "keyboard",
geometry: geometry
)
}
Spacer()
.frame(height: geometry.size.height * 0.06)
// Help text
Text("Visit ente.io/cast for help")
.font(FontUtils.interMedium(size: geometry.size.width * 0.012))
.foregroundColor(.white)
Spacer()
.frame(height: geometry.size.height * 0.04)
}
// Ducky positioned at bottom right of the card
VStack {
Spacer()
HStack {
Spacer()
Image("ducky_camera")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geometry.size.width * 0.27,
height: geometry.size.width * 0.27)
.offset(x: geometry.size.width * 0.18,
y: geometry.size.height * 0.18)
}
}
}
.frame(width: geometry.size.width * 0.8,
height: geometry.size.height * 0.85)
// Right padding (3x)
Spacer()
.frame(width: geometry.size.width * 0.15)
}
.padding(.top, geometry.size.height * 0.05) // Top padding (1x)
.padding(.bottom, geometry.size.height * 0.05) // Bottom padding (1x)
// Ente logo in top right corner
VStack {
HStack {
Spacer()
EnteBranding()
.padding(.top, 30)
.padding(.trailing, 30)
}
Spacer()
}
}
}
.onAppear {
startAnimations()
}
}
private func startAnimations() {
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
pulseScale = 1.05
}
}
}
struct InstructionStep: View {
let number: String
let text: String
let icon: String
let geometry: GeometryProxy
var body: some View {
VStack(spacing: geometry.size.height * 0.015) {
// Circular icon background
ZStack {
Circle()
.fill(Color(red: 0/255, green: 150/255, blue: 51/255))
.frame(width: geometry.size.width * 0.035,
height: geometry.size.width * 0.035)
Image(systemName: icon)
.font(.system(size: geometry.size.width * 0.015, weight: .medium))
.foregroundColor(.white)
}
// Step text
Text(text)
.font(FontUtils.interMedium(size: geometry.size.width * 0.012))
.foregroundColor(.white)
.multilineTextAlignment(.center)
}
}
}
#Preview {
PairingView(deviceCode: "W68GT1")
}

View File

@@ -0,0 +1,677 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct SlideshowView: View {
let imageData: Data?
let videoData: Data?
let isVideo: Bool
@ObservedObject var slideshowService: RealSlideshowService
// Computed property to determine if current file is a live photo
private var isLivePhoto: Bool {
slideshowService.currentFile?.isLivePhoto ?? false
}
@State private var showControls = false
@State private var controlsTimer: Timer?
@State private var imageScale: CGFloat = 1.0
@State private var imageOpacity: Double = 1.0
@State private var previousImageOpacity: Double = 0.0
@State private var previousImageData: Data? = nil
@State private var actionFeedback: ActionFeedback? = nil
@State private var isPlayingLivePhotoVideo = false
@State private var lastImageBytes: Int = 0
@State private var imageDecodeFailed: Bool = false
@State private var showToast = false
@State private var toastMessage = ""
@State private var toastIcon = ""
@State private var slideTimeRemaining: TimeInterval = 0
@State private var lastPauseTime: Date?
@State private var displayImageData: Data? = nil
@State private var preDecodedImage: UIImage? = nil
@State private var previousDecodedImage: UIImage? = nil
#if os(tvOS)
@FocusState private var isFocused: Bool
#endif
init(imageData: Data? = nil, videoData: Data? = nil, isVideo: Bool = false, slideshowService: RealSlideshowService) {
self.imageData = imageData
self.videoData = videoData
self.isVideo = isVideo
self.slideshowService = slideshowService
}
var body: some View {
// OPTIMIZATION: Use pre-decoded images to avoid decoding during render
let mainUIImage = preDecodedImage
let previousUIImage = previousDecodedImage
return GeometryReader { geometry in
ZStack {
// Background layer - FIXED VERSION
if let uiImage = mainUIImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: geometry.size.height)
.blur(radius: 50)
.scaleEffect(2.2)
.overlay(Color.teal.opacity(0.001))
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
.ignoresSafeArea()
} else {
Color.black.ignoresSafeArea()
}
// Foreground content
if isVideo, let videoData = videoData {
VideoPlayerView(
videoData: videoData,
suggestedFilename: slideshowService.currentFile?.title
)
.transition(.asymmetric(
insertion: .scale(scale: 1.1).combined(with: .opacity),
removal: .scale(scale: 0.9).combined(with: .opacity)
))
} else if isLivePhoto && isPlayingLivePhotoVideo,
let liveVideoData = slideshowService.livePhotoVideoData {
VideoPlayerView(
videoData: liveVideoData,
suggestedFilename: slideshowService.currentFile?.title
)
.transition(.asymmetric(
insertion: .scale(scale: 1.1).combined(with: .opacity),
removal: .scale(scale: 0.9).combined(with: .opacity)
))
} else if let uiImage = mainUIImage {
// Crossfade image transition
ZStack {
// Previous image (fading out)
if let prevImage = previousUIImage {
Image(uiImage: prevImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scaleEffect(imageScale)
.opacity(previousImageOpacity)
}
// Current image (fading in)
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scaleEffect(imageScale)
.opacity(imageOpacity)
}
.onAppear {
// Pass image data count for logging purposes
animateImageIn(bytes: displayImageData?.count ?? 0, isLive: isLivePhoto)
}
.onDisappear {
imageScale = 1.0
imageOpacity = 1.0
previousImageOpacity = 0.0
isPlayingLivePhotoVideo = false
}
} else {
// Check if we have an empty state vs loading state
if let error = slideshowService.error,
(error.contains("No media files available") || error.contains("Empty file list")) {
EmptyState()
} else if slideshowService.totalSlides == 0 && !slideshowService.isPlaying {
// Still loading initial file list
LoadingState()
} else {
// Files exist but current slide is loading
LoadingState()
}
}
// Overlays
actionFeedbackOverlay
controlsOverlay
toastOverlay
// ambientLightOverlay
}
}
.onChange(of: imageData) { newValue in
if let newData = newValue {
Task {
// Pre-decode image off main thread
let decodedImage = decodedUIImage(from: newData)
await MainActor.run {
// Store current decoded image as previous for crossfade
previousDecodedImage = preDecodedImage
previousImageData = displayImageData
// Set decoded image first
preDecodedImage = decodedImage
// Set up opacity states
if previousDecodedImage != nil {
previousImageOpacity = 1.0
imageOpacity = 0.0
} else {
// First image - no crossfade needed
imageOpacity = 1.0
previousImageOpacity = 0.0
}
// Then update display data to trigger view update
displayImageData = newData
animateImageIn(bytes: newData.count, isLive: isLivePhoto)
}
}
}
}
.onTapGesture { Task { await handlePlayPauseAction() } }
.onLongPressGesture { handleLongPressGesture() }
#if os(tvOS)
.focusable()
.focused($isFocused)
.onMoveCommand { direction in Task { await handleDirectionalInput(direction) } }
.onPlayPauseCommand {
if isLivePhoto && slideshowService.livePhotoVideoData != nil {
handleLongPressGesture()
} else {
Task { await handlePlayPauseAction() }
}
}
.onExitCommand { toggleControls() }
#endif
.onAppear {
startControlsTimer()
#if os(tvOS)
isFocused = true
// Backup screen saver prevention at view level
ScreenSaverManager.preventScreenSaver()
#endif
// CRITICAL FIX: Process initial image data if present and not already processed
// This handles the case where SlideshowView is created with imageData already populated
// (e.g., when transitioning from .connecting to .slideshow state)
// Without this, onChange won't fire and the image won't display
if let initialImageData = imageData, displayImageData == nil && !isVideo {
Task {
let decodedImage = decodedUIImage(from: initialImageData)
await MainActor.run {
preDecodedImage = decodedImage
imageOpacity = 1.0
previousImageOpacity = 0.0
displayImageData = initialImageData
animateImageIn(bytes: initialImageData.count, isLive: isLivePhoto)
}
}
}
}
.onDisappear {
#if os(tvOS)
// Ensure screen saver prevention is disabled when view disappears
ScreenSaverManager.allowScreenSaver()
#endif
}
}
// MARK: - Overlay Views
@ViewBuilder
private var actionFeedbackOverlay: some View {
if let feedback = actionFeedback {
ActionFeedbackView(feedback: feedback)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
withAnimation(.easeOut(duration: 0.25)) { actionFeedback = nil }
}
}
}
}
@ViewBuilder
private var controlsOverlay: some View {
if showControls {
EnhancedControlsOverlay(
isPlaying: slideshowService.isPlaying,
isPaused: slideshowService.isPaused
)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .opacity
))
}
}
@ViewBuilder
private var toastOverlay: some View {
if showToast {
VStack {
HStack {
AppleStyleToast(icon: toastIcon, message: toastMessage)
Spacer()
}
.padding(.top, 60)
.padding(.leading, 60)
Spacer()
}
}
}
private func handleLongPressGesture() {
showToast(icon: "livephoto.play", message: "Long press called")
guard isLivePhoto, slideshowService.livePhotoVideoData != nil else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isPlayingLivePhotoVideo.toggle()
}
if isPlayingLivePhotoVideo {
slideshowService.pause()
showToast(icon: "livephoto.play", message: "Playing Live Photo")
} else {
slideshowService.resume()
showToast(icon: "livephoto", message: "Live Photo Paused")
}
// Auto-return to image after video plays
if isPlayingLivePhotoVideo {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
withAnimation(.easeInOut(duration: 0.3)) {
isPlayingLivePhotoVideo = false
}
slideshowService.resume()
}
}
}
private func handlePlayPauseAction() async {
slideshowService.togglePlayPause()
await MainActor.run {
// Show toast notification only
if slideshowService.isPaused {
showToast(icon: "pause.fill", message: "Paused")
} else {
showToast(icon: "play.fill", message: "Resumed")
}
}
}
private func handleDirectionalInput(_ direction: MoveCommandDirection) async {
switch direction {
case .left:
await slideshowService.previousSlide()
// No center feedback for navigation
case .right:
await slideshowService.nextSlide()
// No center feedback for navigation
case .up, .down:
toggleControls()
@unknown default:
break
}
}
private func toggleControls() {
withAnimation(.easeInOut(duration: 0.4)) {
showControls.toggle()
}
if showControls {
startControlsTimer()
}
}
private func startControlsTimer() {
controlsTimer?.invalidate()
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
withAnimation(.easeInOut(duration: 0.4)) {
showControls = false
}
}
}
private func showToast(icon: String, message: String) {
toastIcon = icon
toastMessage = message
showToast = true
// Auto-dismiss after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showToast = false
}
}
// MARK: - Image Decoding & Animation
private func decodedUIImage(from data: Data) -> UIImage? {
imageDecodeFailed = false
if let uiImage = UIImage(data: data) {
// Force decompression by accessing cgImage
if let cg = uiImage.cgImage {
return UIImage(cgImage: cg, scale: uiImage.scale, orientation: uiImage.imageOrientation)
}
return uiImage
} else {
imageDecodeFailed = true
print("❌ UIImage decode failed (bytes: \(data.count))")
return nil
}
}
private func animateImageIn(bytes: Int, isLive: Bool) {
lastImageBytes = bytes
imageScale = 1.0
// Opacity states are already set up in onChange, just animate the crossfade
withAnimation(.easeInOut(duration: 0.25)) {
imageOpacity = 1.0
previousImageOpacity = 0.0
}
print("🖼️ Displaying \(isLive ? "live" : "static") image (\(bytes) bytes)")
// Clean up previous image data after animation
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await MainActor.run {
previousImageData = nil
previousImageOpacity = 0.0
}
}
}
}
// MARK: - Supporting Views and Types
struct LoadingState: View {
@State private var animationPhase: CGFloat = 0
@State private var pulseScale: CGFloat = 1.0
var body: some View {
VStack(spacing: 40) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 6)
.frame(width: 120, height: 120)
Circle()
.trim(from: 0, to: 0.3)
.stroke(
LinearGradient(
colors: [Color.blue, Color.purple],
startPoint: .leading,
endPoint: .trailing
),
style: StrokeStyle(lineWidth: 6, lineCap: .round)
)
.frame(width: 120, height: 120)
.rotationEffect(.degrees(animationPhase * 360))
Circle()
.fill(
RadialGradient(
colors: [
Color.blue.opacity(0.3),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 40
)
)
.frame(width: 80, height: 80)
.scaleEffect(pulseScale)
}
}
.onAppear {
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
animationPhase = 1.0
}
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulseScale = 1.2
}
}
}
}
struct EmptyState: View {
@State private var pulseScale: CGFloat = 1.0
var body: some View {
VStack(spacing: 40) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.gray.opacity(0.2),
Color.clear
],
center: .center,
startRadius: 20,
endRadius: 60
)
)
.frame(width: 120, height: 120)
.scaleEffect(pulseScale)
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50, weight: .ultraLight))
.foregroundColor(.white.opacity(0.6))
}
VStack(spacing: 12) {
Text("No photos in this album")
.font(.system(size: 32, weight: .semibold))
.foregroundColor(.white)
Text("Add some photos to start your slideshow")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white.opacity(0.7))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
pulseScale = 1.1
}
}
}
}
struct EnhancedControlsOverlay: View {
let isPlaying: Bool
let isPaused: Bool
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
VStack(spacing: 20) {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: isPaused ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 20, weight: .medium))
.foregroundColor(isPaused ? .yellow : .green)
Text(isPaused ? "Paused" : "Playing")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
Text("Apple TV Remote Controls")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white.opacity(0.8))
}
HStack(spacing: 40) {
NavigationHint(icon: "chevron.left.circle.fill", label: "Previous", direction: .leading)
NavigationHint(icon: isPaused ? "play.circle.fill" : "pause.circle.fill", label: isPaused ? "Resume" : "Pause", direction: .center)
NavigationHint(icon: "chevron.right.circle.fill", label: "Next", direction: .trailing)
}
VStack(spacing: 6) {
Text("• Touch surface: Play/Pause")
Text("• Swipe left/right: Navigate slides")
Text("• Menu button: Show/hide controls")
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(0.7))
}
.padding(32)
.background(
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(0.6))
.background(
RoundedRectangle(cornerRadius: 20).fill(
LinearGradient(
colors: [Color.white.opacity(0.05), Color.white.opacity(0.02)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.15), lineWidth: 1)
}
)
Spacer()
}
.padding(.bottom, 60)
}
}
}
struct NavigationHint: View {
let icon: String
let label: String
let direction: HorizontalAlignment
var body: some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 28, weight: .medium))
Text(label)
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.white.opacity(0.8))
.frame(minWidth: 80)
}
}
enum ActionFeedback {
case play, pause, next, previous
var iconName: String {
switch self {
case .play: "play.circle.fill"
case .pause: "pause.circle.fill"
case .next: "forward.circle.fill"
case .previous: "backward.circle.fill"
}
}
var title: String {
switch self {
case .play: "Play"
case .pause: "Pause"
case .next: "Next"
case .previous: "Previous"
}
}
}
struct ActionFeedbackView: View {
let feedback: ActionFeedback
var body: some View {
VStack(spacing: 8) {
Image(systemName: feedback.iconName)
.font(.system(size: 50, weight: .medium))
Text(feedback.title)
.font(.system(size: 20, weight: .semibold))
}
.foregroundColor(.white)
.padding(24)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.black.opacity(0.7))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.white.opacity(0.2), lineWidth: 1))
)
.transition(.scale.combined(with: .opacity))
}
}
struct AppleStyleToast: View {
let icon: String
let message: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
Text(message)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.8))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
)
.shadow(color: Color.black.opacity(0.3), radius: 8, x: 0, y: 4)
}
}
// MARK: - Preview
class MockSlideshowService: ObservableObject {
@Published var currentFile: (title: String, isLivePhoto: Bool)? = (title: "Sample Image", isLivePhoto: false)
@Published var isPlaying: Bool = true
@Published var isPaused: Bool = false
@Published var livePhotoVideoData: Data? = nil
func togglePlayPause() { isPaused.toggle() }
func nextSlide() async {}
func previousSlide() async {}
func pause() { isPaused = true }
func resume() { isPaused = false }
}
#Preview {
struct PreviewWrapper: View {
@StateObject private var slideshowService = MockSlideshowService()
var body: some View {
SlideshowView(
imageData: nil,
slideshowService: slideshowService as! RealSlideshowService
)
}
}
return PreviewWrapper()
}

View File

@@ -0,0 +1,149 @@
//
// StatusView.swift
// tv
//
// Created by Neeraj Gupta on 28/08/25.
//
import SwiftUI
struct StatusView: View {
let status: StatusType
let onRetry: (() -> Void)?
let debugLogs: String?
@State private var animationPhase: CGFloat = 0
@State private var pulseScale: CGFloat = 1.0
enum StatusType {
case loading(String)
case error(String)
case success(String)
case empty(String)
}
var body: some View {
GeometryReader { geometry in
ZStack {
// White background
Color.white
.ignoresSafeArea()
// Main content
VStack(spacing: 24) {
Spacer()
// Status icon - clean display without backgrounds
StatusIcon(status: status)
.padding(.bottom, 16)
// Title
Text(title)
.font(FontUtils.interSemiBold(size: 42))
.foregroundColor(.black)
.multilineTextAlignment(.center)
// Message (if not empty)
if !message.isEmpty {
Text(message)
.font(FontUtils.interRegular(size: 20))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 60)
}
Spacer()
}
.frame(maxWidth: 900)
.frame(maxWidth: .infinity)
// Ente branding in top right corner
VStack {
HStack {
Spacer()
EnteBranding()
.padding(.top, 30)
.padding(.trailing, 30)
}
Spacer()
}
}
}
}
private var title: String {
switch status {
case .loading:
return "Preparing Slideshow"
case .error:
return "Something went wrong"
case .success:
return "All set!"
case .empty:
return "No photos found"
}
}
private var message: String {
switch status {
case .loading(let message):
return message
case .error(let message):
return message
case .success(let message):
return message
case .empty(let message):
return message
}
}
}
struct StatusIcon: View {
let status: StatusView.StatusType
var body: some View {
// Simple icon display without any backgrounds or effects
iconView
.frame(width: 400, height: 240)
}
@ViewBuilder
private var iconView: some View {
switch status {
case .loading:
Image("ducky_tv")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 300)
case .error:
Image("ducky_tv")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 300)
.opacity(0.8)
case .success:
Image("ducky_tv")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 300)
case .empty:
Image("ducky_tv")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 300)
.opacity(0.7)
}
}
}
#Preview {
StatusView(status: .loading("Preparing your slideshow..."), onRetry: nil, debugLogs: nil)
}
#Preview {
StatusView(status: .empty("This album has no photos that can be shown here"), onRetry: nil, debugLogs: nil)
}

View File

@@ -0,0 +1,332 @@
import SwiftUI
import AVFoundation
import AVKit
#if canImport(UIKit)
import UIKit
#endif
struct VideoPlayerView: View {
let videoData: Data
let suggestedFilename: String?
@State private var player: AVPlayer?
@State private var isPlaying = false
@State private var playerItem: AVPlayerItem?
@State private var showToast = false
@State private var toastMessage = ""
@State private var toastIcon = ""
init(videoData: Data, suggestedFilename: String? = nil) {
self.videoData = videoData
self.suggestedFilename = suggestedFilename
}
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
if let player = player {
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear {
setupPlayer()
}
.onDisappear {
cleanup()
}
} else {
// Clean loading state
VStack(spacing: 24) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color(red: 29/255, green: 185/255, blue: 84/255)))
.scaleEffect(2.0)
Text("Loading video...")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white.opacity(0.8))
}
}
}
.onAppear {
setupVideoPlayer()
}
.onDisappear {
cleanup()
}
}
private func setupVideoPlayer() {
Task {
do {
// Extract file extension from suggested filename
let suggestedExtension = suggestedFilename?.components(separatedBy: ".").last?.lowercased()
// Create temporary file for video data with proper extension
let tempURL = try await createTemporaryVideoFile(from: videoData, suggestedExtension: suggestedExtension)
await MainActor.run {
// Validate that the video file can be played
let asset = AVURLAsset(url: tempURL)
// Check if the asset is playable
Task {
let isPlayable = try await asset.load(.isPlayable)
let hasVideoTracks = try await !asset.loadTracks(withMediaType: .video).isEmpty
await MainActor.run {
if isPlayable && hasVideoTracks {
let playerItem = AVPlayerItem(url: tempURL)
let player = AVPlayer(playerItem: playerItem)
// Monitor player item status using modern async/await approach
self.monitorPlayerItemStatus(playerItem)
self.playerItem = playerItem
self.player = player
setupPlayer()
} else {
print("❌ Video asset is not playable or has no video tracks")
// Try fallback with different extension
self.tryVideoFallback(originalURL: tempURL)
}
}
}
}
} catch {
print("❌ Failed to setup video player: \(error)")
await MainActor.run {
// Show error state
self.showErrorState()
}
}
}
}
private func tryVideoFallback(originalURL: URL) {
// Try creating a new temp file with .mov extension as fallback
Task {
do {
let fallbackURL = originalURL.deletingPathExtension().appendingPathExtension("mov")
try FileManager.default.copyItem(at: originalURL, to: fallbackURL)
await MainActor.run {
let playerItem = AVPlayerItem(url: fallbackURL)
let player = AVPlayer(playerItem: playerItem)
self.playerItem = playerItem
self.player = player
setupPlayer()
}
} catch {
print("❌ Video fallback also failed: \(error)")
showErrorState()
}
}
}
private func showErrorState() {
// Could show an error message or placeholder
print("❌ Unable to play video - showing error state")
}
private func monitorPlayerItemStatus(_ playerItem: AVPlayerItem) {
// Monitor status using a timer-based approach since we can't use KVO in SwiftUI structs
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
switch playerItem.status {
case .readyToPlay:
print("✅ Video player ready to play")
timer.invalidate()
case .failed:
if let error = playerItem.error {
print("❌ Video player failed with error: \(error)")
print("❌ Error details: \(error.localizedDescription)")
}
timer.invalidate()
Task { @MainActor in
self.showErrorState()
}
case .unknown:
print("⏳ Video player status unknown")
// Keep checking
@unknown default:
timer.invalidate()
}
}
}
private func setupPlayer() {
guard let player = player else { return }
// Configure player for TV playback
player.actionAtItemEnd = .none
player.automaticallyWaitsToMinimizeStalling = true
// Set audio session for proper audio handling
#if os(tvOS)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Failed to set audio session: \(error)")
}
#endif
// Add observers
setupPlayerObservers()
// Start playing
player.play()
isPlaying = true
}
private func setupPlayerObservers() {
guard let player = player, let currentItem = player.currentItem else { return }
// Add observer for when video ends
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: currentItem,
queue: .main
) { _ in
// Loop the video
player.seek(to: .zero)
player.play()
}
// Add observer for player status changes
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: currentItem,
queue: .main
) { notification in
if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
print("❌ Video playback failed: \(error)")
}
}
// Add observer for app lifecycle
NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
player.pause()
}
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
if self.isPlaying {
player.play()
}
}
}
private func createTemporaryVideoFile(from data: Data, suggestedExtension: String? = nil) async throws -> URL {
let tempDirectory = FileManager.default.temporaryDirectory
let fileExtension = detectVideoExtension(from: data) ?? suggestedExtension ?? "mp4"
let tempURL = tempDirectory.appendingPathComponent("cast_video_\(UUID().uuidString).\(fileExtension)")
try data.write(to: tempURL)
// Schedule cleanup of temp file after some time
Task {
try? await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds
try? FileManager.default.removeItem(at: tempURL)
}
return tempURL
}
private func detectVideoExtension(from data: Data) -> String? {
let headerBytes = data.prefix(32) // Read more bytes for better detection
if headerBytes.count >= 4 {
let signature = headerBytes.prefix(4)
// MP4/MOV formats (most compatible with AVPlayer)
if headerBytes.count >= 12 {
let ftyp = headerBytes.subdata(in: 4..<8)
if ftyp == Data("ftyp".utf8) {
let brand = headerBytes.subdata(in: 8..<12)
if brand == Data("mp41".utf8) || brand == Data("mp42".utf8) ||
brand == Data("isom".utf8) || brand == Data("M4V ".utf8) {
print("🎥 Detected MP4 video format")
return "mp4"
} else if brand == Data("qt ".utf8) {
print("🎥 Detected MOV video format")
return "mov"
}
}
}
// Check for H.264 NAL units (common in MP4)
if headerBytes.count >= 4 {
if signature[0] == 0x00 && signature[1] == 0x00 && signature[2] == 0x00 && signature[3] == 0x01 {
print("🎥 Detected H.264 stream, using MP4 container")
return "mp4"
}
}
// AVI format (less compatible with tvOS)
if signature == Data("RIFF".utf8) && headerBytes.count >= 12 {
let aviSignature = headerBytes.subdata(in: 8..<12)
if aviSignature == Data("AVI ".utf8) {
print("⚠️ Detected AVI format - may have compatibility issues")
return "avi"
}
}
// WebM format (limited support on tvOS)
if signature == Data([0x1A, 0x45, 0xDF, 0xA3]) {
print("⚠️ Detected WebM format - may have compatibility issues")
return "webm"
}
// MKV format
if signature == Data([0x1A, 0x45, 0xDF, 0xA3]) {
print("⚠️ Detected MKV format - may have compatibility issues")
return "mkv"
}
}
print("🎥 Unknown video format, defaulting to MP4")
return "mp4"
}
private func cleanup() {
player?.pause()
// KVO observer removal no longer needed as we use modern async/await approach
// Remove all notification observers
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
// Deactivate audio session
#if os(tvOS)
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
} catch {
print("Failed to deactivate audio session: \(error)")
}
#endif
player = nil
playerItem = nil
isPlaying = false
}
}
#Preview {
// Create mock video data for preview
let mockData = Data()
VideoPlayerView(videoData: mockData, suggestedFilename: "sample_video.mp4")
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct tvApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,635 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
DACC3DAB2E604C9C00DAF993 /* Sodium in Frameworks */ = {isa = PBXBuildFile; productRef = DA72BCAB2E5F87B000D128FF /* Sodium */; };
DACC3DC92E618D6A00DAF993 /* EnteCrypto in Frameworks */ = {isa = PBXBuildFile; productRef = DACC3DC82E618D6A00DAF993 /* EnteCrypto */; };
DAF584672E61BB8300F8A08F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = DAF584662E61BB8300F8A08F /* ZIPFoundation */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
DA72BC742E5F87A100D128FF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DA72BC592E5F87A000D128FF /* Project object */;
proxyType = 1;
remoteGlobalIDString = DA72BC602E5F87A000D128FF;
remoteInfo = tv;
};
DA72BC7E2E5F87A100D128FF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DA72BC592E5F87A000D128FF /* Project object */;
proxyType = 1;
remoteGlobalIDString = DA72BC602E5F87A000D128FF;
remoteInfo = tv;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
DA72BC612E5F87A000D128FF /* cast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cast.app; sourceTree = BUILT_PRODUCTS_DIR; };
DA72BC732E5F87A100D128FF /* castTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = castTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DA72BC7D2E5F87A100D128FF /* castUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = castUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
DA01087C2E682336006E79AE /* Exceptions for "cast" folder in "cast" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = DA72BC602E5F87A000D128FF /* cast */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
DA72BC632E5F87A000D128FF /* cast */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
DA01087C2E682336006E79AE /* Exceptions for "cast" folder in "cast" target */,
);
path = cast;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
DA72BC5E2E5F87A000D128FF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DACC3DAB2E604C9C00DAF993 /* Sodium in Frameworks */,
DACC3DC92E618D6A00DAF993 /* EnteCrypto in Frameworks */,
DAF584672E61BB8300F8A08F /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC702E5F87A100D128FF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC7A2E5F87A100D128FF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
DA72BC582E5F87A000D128FF = {
isa = PBXGroup;
children = (
DA72BC632E5F87A000D128FF /* cast */,
DACC3DC72E618D6A00DAF993 /* Frameworks */,
DA72BC622E5F87A000D128FF /* Products */,
);
sourceTree = "<group>";
};
DA72BC622E5F87A000D128FF /* Products */ = {
isa = PBXGroup;
children = (
DA72BC612E5F87A000D128FF /* cast.app */,
DA72BC732E5F87A100D128FF /* castTests.xctest */,
DA72BC7D2E5F87A100D128FF /* castUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
DACC3DC72E618D6A00DAF993 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
DA72BC602E5F87A000D128FF /* cast */ = {
isa = PBXNativeTarget;
buildConfigurationList = DA72BC872E5F87A100D128FF /* Build configuration list for PBXNativeTarget "cast" */;
buildPhases = (
DA72BC5D2E5F87A000D128FF /* Sources */,
DA72BC5E2E5F87A000D128FF /* Frameworks */,
DA72BC5F2E5F87A000D128FF /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
DA72BC632E5F87A000D128FF /* cast */,
);
name = cast;
packageProductDependencies = (
DA72BCAB2E5F87B000D128FF /* Sodium */,
DACC3DC82E618D6A00DAF993 /* EnteCrypto */,
DAF584662E61BB8300F8A08F /* ZIPFoundation */,
);
productName = tv;
productReference = DA72BC612E5F87A000D128FF /* cast.app */;
productType = "com.apple.product-type.application";
};
DA72BC722E5F87A100D128FF /* castTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = DA72BC8A2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castTests" */;
buildPhases = (
DA72BC6F2E5F87A100D128FF /* Sources */,
DA72BC702E5F87A100D128FF /* Frameworks */,
DA72BC712E5F87A100D128FF /* Resources */,
);
buildRules = (
);
dependencies = (
DA72BC752E5F87A100D128FF /* PBXTargetDependency */,
);
name = castTests;
packageProductDependencies = (
);
productName = tvTests;
productReference = DA72BC732E5F87A100D128FF /* castTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
DA72BC7C2E5F87A100D128FF /* castUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = DA72BC8D2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castUITests" */;
buildPhases = (
DA72BC792E5F87A100D128FF /* Sources */,
DA72BC7A2E5F87A100D128FF /* Frameworks */,
DA72BC7B2E5F87A100D128FF /* Resources */,
);
buildRules = (
);
dependencies = (
DA72BC7F2E5F87A100D128FF /* PBXTargetDependency */,
);
name = castUITests;
packageProductDependencies = (
);
productName = tvUITests;
productReference = DA72BC7D2E5F87A100D128FF /* castUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
DA72BC592E5F87A000D128FF /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1630;
LastUpgradeCheck = 1630;
TargetAttributes = {
DA72BC602E5F87A000D128FF = {
CreatedOnToolsVersion = 16.3;
};
DA72BC722E5F87A100D128FF = {
CreatedOnToolsVersion = 16.3;
TestTargetID = DA72BC602E5F87A000D128FF;
};
DA72BC7C2E5F87A100D128FF = {
CreatedOnToolsVersion = 16.3;
TestTargetID = DA72BC602E5F87A000D128FF;
};
};
};
buildConfigurationList = DA72BC5C2E5F87A000D128FF /* Build configuration list for PBXProject "tv" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = DA72BC582E5F87A000D128FF;
minimizedProjectReferenceProxies = 1;
packageReferences = (
DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */,
DACC3DC62E618D5300DAF993 /* XCLocalSwiftPackageReference "../../Packages/EnteCrypto" */,
DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = DA72BC622E5F87A000D128FF /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
DA72BC602E5F87A000D128FF /* cast */,
DA72BC722E5F87A100D128FF /* castTests */,
DA72BC7C2E5F87A100D128FF /* castUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
DA72BC5F2E5F87A000D128FF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC712E5F87A100D128FF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC7B2E5F87A100D128FF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DA72BC5D2E5F87A000D128FF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC6F2E5F87A100D128FF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DA72BC792E5F87A100D128FF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
DA72BC752E5F87A100D128FF /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DA72BC602E5F87A000D128FF /* cast */;
targetProxy = DA72BC742E5F87A100D128FF /* PBXContainerItemProxy */;
};
DA72BC7F2E5F87A100D128FF /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DA72BC602E5F87A000D128FF /* cast */;
targetProxy = DA72BC7E2E5F87A100D128FF /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
DA72BC852E5F87A100D128FF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SN6GQPQWGV;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = appletvos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TVOS_DEPLOYMENT_TARGET = 18.4;
};
name = Debug;
};
DA72BC862E5F87A100D128FF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SN6GQPQWGV;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = appletvos;
SWIFT_COMPILATION_MODE = wholemodule;
TVOS_DEPLOYMENT_TARGET = 18.4;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
DA72BC882E5F87A100D128FF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = cast/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Cast";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.cast;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
};
name = Debug;
};
DA72BC892E5F87A100D128FF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = cast/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "";
INFOPLIST_KEY_CFBundleDisplayName = "Ente Cast";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.frame.tv.cast;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
};
name = Release;
};
DA72BC8B2E5F87A100D128FF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SN6GQPQWGV;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.castTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tv";
TVOS_DEPLOYMENT_TARGET = 18.4;
};
name = Debug;
};
DA72BC8C2E5F87A100D128FF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SN6GQPQWGV;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tvTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tv";
TVOS_DEPLOYMENT_TARGET = 18.4;
};
name = Release;
};
DA72BC8E2E5F87A100D128FF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SN6GQPQWGV;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.castUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TEST_TARGET_NAME = tv;
};
name = Debug;
};
DA72BC8F2E5F87A100D128FF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SN6GQPQWGV;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tvUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TEST_TARGET_NAME = tv;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
DA72BC5C2E5F87A000D128FF /* Build configuration list for PBXProject "tv" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DA72BC852E5F87A100D128FF /* Debug */,
DA72BC862E5F87A100D128FF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DA72BC872E5F87A100D128FF /* Build configuration list for PBXNativeTarget "cast" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DA72BC882E5F87A100D128FF /* Debug */,
DA72BC892E5F87A100D128FF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DA72BC8A2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DA72BC8B2E5F87A100D128FF /* Debug */,
DA72BC8C2E5F87A100D128FF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DA72BC8D2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DA72BC8E2E5F87A100D128FF /* Debug */,
DA72BC8F2E5F87A100D128FF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
DACC3DC62E618D5300DAF993 /* XCLocalSwiftPackageReference "../../Packages/EnteCrypto" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../Packages/EnteCrypto;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jedisct1/swift-sodium.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.9.1;
};
};
DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/weichsel/ZIPFoundation.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.9.19;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
DA72BCAB2E5F87B000D128FF /* Sodium */ = {
isa = XCSwiftPackageProductDependency;
package = DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */;
productName = Sodium;
};
DACC3DC82E618D6A00DAF993 /* EnteCrypto */ = {
isa = XCSwiftPackageProductDependency;
productName = EnteCrypto;
};
DAF584662E61BB8300F8A08F /* ZIPFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = DA72BC592E5F87A000D128FF /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,69 @@
{
"originHash" : "eae515a67704e4d9ab3d2f6ae0b16f46b4842cc00046f3ff034662395dc1b1bf",
"pins" : [
{
"identity" : "bigint",
"kind" : "remoteSourceControl",
"location" : "https://github.com/attaswift/BigInt.git",
"state" : {
"revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe",
"version" : "5.7.0"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
"version" : "1.4.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34",
"version" : "3.15.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
"identity" : "swift-sodium",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jedisct1/swift-sodium.git",
"state" : {
"revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342",
"version" : "0.9.1"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged.git",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
"version" : "0.9.19"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
}
],
"version" : 2
}

View File

@@ -0,0 +1,40 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "EnteCast",
platforms: [
.iOS(.v15),
.tvOS(.v15),
.macOS(.v12)
],
products: [
.library(
name: "EnteCast",
targets: ["EnteCast"]
),
],
dependencies: [
// TODO: Uncomment when these packages are available:
// .package(path: "../EnteCore"),
// .package(path: "../EnteNetwork"),
// .package(path: "../EnteCrypto"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
],
targets: [
.target(
name: "EnteCast",
dependencies: [
// TODO: Uncomment when these packages are available:
// "EnteCore",
// "EnteNetwork",
// "EnteCrypto",
.product(name: "Logging", package: "swift-log"),
]
),
.testTarget(
name: "EnteCastTests",
dependencies: ["EnteCast"]
),
]
)

View File

@@ -0,0 +1,10 @@
import Foundation
public struct EnteCast {
public static let version = "1.0.0"
public init() {}
}
// Re-export stub types for convenience
// TODO: Replace with actual Ente package exports when available

View File

@@ -0,0 +1,45 @@
import Foundation
public enum CastError: Error, LocalizedError {
case networkError(String)
case decryptionError(String)
case invalidImageData
case imageConversionFailed
case videoProcessingFailed
case fileNotFound
case invalidFileType
case fileSizeTooLarge
case deviceRegistrationFailed
case pairingTimeout
case invalidCastToken
case slideshowStopped
public var errorDescription: String? {
switch self {
case .networkError(let message):
return "Network error: \(message)"
case .decryptionError(let message):
return "Decryption error: \(message)"
case .invalidImageData:
return "Invalid image data"
case .imageConversionFailed:
return "Failed to convert image"
case .videoProcessingFailed:
return "Failed to process video"
case .fileNotFound:
return "File not found"
case .invalidFileType:
return "Unsupported file type"
case .fileSizeTooLarge:
return "File size too large"
case .deviceRegistrationFailed:
return "Failed to register device"
case .pairingTimeout:
return "Pairing timed out"
case .invalidCastToken:
return "Invalid cast token"
case .slideshowStopped:
return "Slideshow was stopped"
}
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
public struct CastFileMetadata: Codable, Equatable {
public let fileType: Int
public let title: String
public let creationTime: Int64?
public init(fileType: Int, title: String, creationTime: Int64? = nil) {
self.fileType = fileType
self.title = title
self.creationTime = creationTime
}
}
public struct CastFileInfo: Codable, Equatable {
public let fileSize: Int64?
public init(fileSize: Int64?) {
self.fileSize = fileSize
}
}
public struct CastFileData: Codable, Equatable {
public let decryptionHeader: String
public init(decryptionHeader: String) {
self.decryptionHeader = decryptionHeader
}
}
public struct CastFile: Codable, Equatable {
public let id: FileID
public let metadata: CastFileMetadata
public let info: CastFileInfo?
public let file: CastFileData
public let thumbnail: CastFileData
public let key: String
public let updationTime: Int64
public let isDeleted: Bool
public init(
id: FileID,
metadata: CastFileMetadata,
info: CastFileInfo?,
file: CastFileData,
thumbnail: CastFileData,
key: String,
updationTime: Int64,
isDeleted: Bool
) {
self.id = id
self.metadata = metadata
self.info = info
self.file = file
self.thumbnail = thumbnail
self.key = key
self.updationTime = updationTime
self.isDeleted = isDeleted
}
public var fileName: String {
return metadata.title
}
public var fileSize: Int64 {
return info?.fileSize ?? 0
}
// File type constants from the web app
public var isImage: Bool {
return metadata.fileType == 0 // FileType.image
}
public var isVideo: Bool {
return metadata.fileType == 1 // FileType.video
}
public var isLivePhoto: Bool {
return metadata.fileType == 2 // FileType.livePhoto
}
public func isEligibleForSlideshow(with configuration: SlideConfiguration) -> Bool {
guard isImage || isLivePhoto || (isVideo && configuration.includeVideos) else { return false }
// Check file size limits
let maxSize: Int64 = isVideo ? configuration.maxVideoSize : configuration.maxImageSize
guard fileSize <= maxSize else { return false }
return true
}
public var fileExtension: String? {
let title = fileName.lowercased()
return URL(fileURLWithPath: title).pathExtension.isEmpty ? nil : URL(fileURLWithPath: title).pathExtension
}
public var isHEIC: Bool {
return fileExtension == "heic" || fileExtension == "heif"
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
public enum CastSessionState: Equatable {
case idle
case registering
case waitingForPairing(deviceCode: String)
case connected(CastPayload)
case error(String)
}
public struct CastPayload: Codable, Equatable {
public let collectionID: CollectionID
public let collectionKey: String
public let castToken: String
public init(collectionID: CollectionID, collectionKey: String, castToken: String) {
self.collectionID = collectionID
self.collectionKey = collectionKey
self.castToken = castToken
}
}
public struct CastDevice: Codable, Equatable {
public let deviceCode: String
public let publicKey: String
public let privateKey: String
public init(deviceCode: String, publicKey: String, privateKey: String) {
self.deviceCode = deviceCode
self.publicKey = publicKey
self.privateKey = privateKey
}
}
@MainActor
public class CastSession: ObservableObject {
@Published public var state: CastSessionState = .idle
@Published public var isActive: Bool = false
public var deviceCode: String? {
if case .waitingForPairing(let code) = state {
return code
}
return nil
}
public var payload: CastPayload? {
if case .connected(let payload) = state {
return payload
}
return nil
}
public init() {}
public func setState(_ newState: CastSessionState) {
state = newState
isActive = !isIdle
}
public var isIdle: Bool {
if case .idle = state {
return true
}
return false
}
public var isWaitingForPairing: Bool {
if case .waitingForPairing = state {
return true
}
return false
}
public var isConnected: Bool {
if case .connected = state {
return true
}
return false
}
public var hasError: Bool {
if case .error = state {
return true
}
return false
}
}

View File

@@ -0,0 +1,64 @@
import Foundation
public struct SlideConfiguration {
public let imageDuration: TimeInterval
public let videoDuration: TimeInterval
public let useThumbnails: Bool
public let shuffle: Bool
public let maxImageSize: Int64
public let maxVideoSize: Int64
public let includeVideos: Bool
// Enhanced prefetch settings
public let prefetchCount: Int
public let maxCacheSize: Int
public let prefetchDelay: TimeInterval
public let enablePrefetching: Bool
public init(
imageDuration: TimeInterval = 12.0,
videoDuration: TimeInterval = 30.0,
useThumbnails: Bool = false,
shuffle: Bool = true,
maxImageSize: Int64 = 100 * 1024 * 1024, // 100MB
maxVideoSize: Int64 = 500 * 1024 * 1024, // 500MB
includeVideos: Bool = true,
prefetchCount: Int = 3,
maxCacheSize: Int = 5,
prefetchDelay: TimeInterval = 0.5,
enablePrefetching: Bool = true
) {
self.imageDuration = imageDuration
self.videoDuration = videoDuration
self.useThumbnails = useThumbnails
self.shuffle = shuffle
self.maxImageSize = maxImageSize
self.maxVideoSize = maxVideoSize
self.includeVideos = includeVideos
self.prefetchCount = prefetchCount
self.maxCacheSize = maxCacheSize
self.prefetchDelay = prefetchDelay
self.enablePrefetching = enablePrefetching
}
public func duration(for file: CastFile) -> TimeInterval {
return file.isVideo ? videoDuration : imageDuration
}
public static let `default` = SlideConfiguration()
// TV-optimized configuration (use thumbnails for better performance)
public static let tvOptimized = SlideConfiguration(
imageDuration: 8.0, // Faster progression for TV viewing
videoDuration: 25.0,
useThumbnails: true, // Better performance on Apple TV
shuffle: true,
maxImageSize: 50 * 1024 * 1024, // 50MB for TV
maxVideoSize: 200 * 1024 * 1024, // 200MB for TV
includeVideos: true,
prefetchCount: 3, // Prefetch next 3 slides
maxCacheSize: 5, // Keep 5 slides in cache
prefetchDelay: 0.5, // 500ms delay between prefetch operations
enablePrefetching: true // Enable prefetching for smooth transitions
)
}

View File

@@ -0,0 +1,184 @@
import Foundation
import Logging
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AVFoundation)
import AVFoundation
#endif
public class CastFileService {
private let castGateway: CastGateway
private let logger = Logger(label: "CastFileService")
public init(castGateway: CastGateway) {
self.castGateway = castGateway
}
// MARK: - File Discovery
public func fetchAllFiles(castToken: String) async throws -> [CastFile] {
logger.info("Fetching all cast files")
var allFiles: [FileID: CastFile] = [:]
var sinceTime: Int64 = 0
while true {
let diff = try await castGateway.getDiff(sinceTime: sinceTime)
guard !diff.diff.isEmpty else { break }
for change in diff.diff {
sinceTime = max(sinceTime, change.updationTime)
if change.isDeleted {
allFiles.removeValue(forKey: change.id)
} else {
// Convert network model to our model
// Note: This is a simplified conversion
// In practice, we'd need to decrypt the file metadata
let castFile = CastFile(
id: change.id,
metadata: CastFileMetadata(fileType: 0, title: "File \(change.id)"), // TODO: Get real metadata
info: CastFileInfo(fileSize: nil),
file: CastFileData(decryptionHeader: ""), // TODO: Get real headers
thumbnail: CastFileData(decryptionHeader: ""),
key: "", // TODO: Get real key
updationTime: change.updationTime,
isDeleted: false
)
allFiles[change.id] = castFile
}
}
if !diff.hasMore { break }
}
let files = Array(allFiles.values)
logger.info("Fetched \(files.count) files")
return files
}
// MARK: - File Download
public func downloadFile(
castFile: CastFile,
castToken: String,
useThumbnail: Bool = false
) async throws -> Data {
logger.info("Downloading file \(castFile.id), thumbnail: \(useThumbnail)")
let encryptedData: Data
let decryptionHeader: String
if useThumbnail {
encryptedData = try await castGateway.getThumbnail(fileID: castFile.id)
decryptionHeader = castFile.thumbnail.decryptionHeader
} else {
encryptedData = try await castGateway.getFile(fileID: castFile.id)
decryptionHeader = castFile.file.decryptionHeader
}
// Decrypt the file data
let decryptedData = try await decryptFileData(
encryptedData: encryptedData,
key: castFile.key,
decryptionHeader: decryptionHeader
)
// Process the file based on its type
return try await processFileData(decryptedData, castFile: castFile)
}
private func decryptFileData(
encryptedData: Data,
key: String,
decryptionHeader: String
) async throws -> Data {
do {
logger.info("Decrypting file data: \(encryptedData.count) bytes encrypted")
// Use proper stream decryption
let decryptedData = try CryptoUtil.decryptFileData(
encryptedData: encryptedData,
base64Key: key,
base64Header: decryptionHeader
)
logger.info("Successfully decrypted \(encryptedData.count) bytes to \(decryptedData.count) bytes")
return decryptedData
} catch {
logger.error("Stream decryption failed: \(error)")
throw CastError.decryptionError(error.localizedDescription)
}
}
// MARK: - File Processing
public func processFileData(_ data: Data, castFile: CastFile) async throws -> Data {
if castFile.isImage {
return try await processImageData(data, castFile: castFile)
} else if castFile.isVideo {
return try await processVideoData(data, castFile: castFile)
} else if castFile.isLivePhoto {
return try await processLivePhotoData(data, castFile: castFile)
}
return data
}
private func processImageData(_ data: Data, castFile: CastFile) async throws -> Data {
#if canImport(UIKit)
guard let image = UIImage(data: data) else {
throw CastError.invalidImageData
}
// Convert HEIC to JPEG for better tvOS compatibility
if castFile.isHEIC {
guard let jpegData = image.jpegData(compressionQuality: 0.85) else {
throw CastError.imageConversionFailed
}
return jpegData
}
return data
#else
return data
#endif
}
private func processVideoData(_ data: Data, castFile: CastFile) async throws -> Data {
// For videos, we might want to extract a thumbnail or process metadata
// For now, return the data as-is
return data
}
private func processLivePhotoData(_ data: Data, castFile: CastFile) async throws -> Data {
// For live photos, extract the still image component
#if canImport(UIKit)
guard let image = UIImage(data: data) else {
throw CastError.invalidImageData
}
// Convert to JPEG for slideshow display
guard let jpegData = image.jpegData(compressionQuality: 0.85) else {
throw CastError.imageConversionFailed
}
return jpegData
#else
return data
#endif
}
// MARK: - File Filtering
public func filterEligibleFiles(_ files: [CastFile], configuration: SlideConfiguration) -> [CastFile] {
return files.filter { $0.isEligibleForSlideshow(with: configuration) }
}
public func shuffleFiles(_ files: [CastFile]) -> [CastFile] {
return files.shuffled()
}
}

View File

@@ -0,0 +1,140 @@
import Foundation
import Logging
import EnteCrypto
import EnteNetwork
public class CastPairingService {
private let castGateway: CastGateway
private let logger = Logger(label: "CastPairingService")
private var pollingTask: Task<Void, Never>?
public init(castGateway: CastGateway) {
self.castGateway = castGateway
}
deinit {
stopPolling()
}
// MARK: - Device Registration
public func registerDevice() async throws -> CastDevice {
logger.info("Starting device registration")
// Generate real X25519 keypair for this session
let keyPair = EnteCrypto.generateKeyPair()
logger.debug("Generated keypair for cast session")
// Register with the server to get a device code
let response = try await castGateway.registerDevice()
logger.info("Device registered successfully with code: \(response.code)")
return CastDevice(
deviceCode: response.code,
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
)
}
// MARK: - Polling for Cast Payload
public func startPolling(
device: CastDevice,
onPayloadReceived: @escaping (CastPayload) -> Void,
onError: @escaping (Error) -> Void
) {
logger.info("Starting polling for device code: \(device.deviceCode)")
stopPolling() // Stop any existing polling
pollingTask = Task {
await pollForPayload(
device: device,
onPayloadReceived: onPayloadReceived,
onError: onError
)
}
}
public func stopPolling() {
logger.info("Stopping polling")
pollingTask?.cancel()
pollingTask = nil
}
private func pollForPayload(
device: CastDevice,
onPayloadReceived: @escaping (CastPayload) -> Void,
onError: @escaping (Error) -> Void
) async {
let pollInterval: TimeInterval = 2.0 // Poll every 2 seconds
let maxAttempts = 150 // 5 minutes total (150 * 2 seconds)
var attempts = 0
while !Task.isCancelled && attempts < maxAttempts {
do {
attempts += 1
// Check if payload is available
if let payload = try await checkForPayload(device: device) {
logger.info("Payload received successfully")
await MainActor.run {
onPayloadReceived(payload)
}
return
}
// Wait before next poll
try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))
} catch {
logger.error("Error during polling: \(error)")
await MainActor.run {
onError(error)
}
return
}
}
if attempts >= maxAttempts {
logger.warning("Polling timeout reached")
await MainActor.run {
onError(EnteError.configurationError("Pairing timeout"))
}
}
}
private func checkForPayload(device: CastDevice) async throws -> CastPayload? {
// This implements the web app's getCastPayload function
// Check if encrypted payload is available and decrypt it
do {
let castData = try await castGateway.getCastData(deviceCode: device.deviceCode)
// The response from museum contains encrypted payload data
// We need to implement decryption similar to web implementation
// TODO: Once the server API returns encrypted data, implement decryption:
// 1. Get encrypted payload from castData
// 2. Decrypt using our private key: EnteCrypto.sealedBoxOpen(encryptedData, publicKey, privateKey)
// 3. Parse the decrypted JSON to get CastPayload
// For now, convert from network model to our model
// This assumes the server returns the raw data (not encrypted)
return CastPayload(
collectionID: castData.collectionID,
collectionKey: "temp-key", // This should come from decrypted payload
castToken: castData.castToken
)
} catch {
// If the error is "not found" or similar, it means no payload yet
if let enteError = error as? EnteError,
case .serverError(let code, _) = enteError,
code == 404 {
return nil // No payload available yet
}
throw error // Re-throw other errors
}
}
}

View File

@@ -0,0 +1,348 @@
import Foundation
import Logging
public class SlideshowService: ObservableObject {
@Published public var currentImageData: Data?
@Published public var currentVideoData: Data?
@Published public var currentFile: CastFile?
@Published public var isLoading: Bool = false
@Published public var error: String?
// Enhanced state management
@Published public var isPlaying: Bool = false
@Published public var isPaused: Bool = false
@Published public var slideLoadingProgress: Double = 0.0
@Published public var currentSlideIndex: Int = 0
@Published public var totalSlides: Int = 0
private let fileService: CastFileService
private let configuration: SlideConfiguration
private let logger = Logger(label: "SlideshowService")
private var slideshowTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>?
private var currentFiles: [CastFile] = []
private var currentIndex: Int = 0
private var castToken: String = ""
private var slideTimer: Timer?
// Prefetching cache
private var prefetchCache: [Int: Data] = [:]
private let prefetchQueue = DispatchQueue(label: "slideshow.prefetch", qos: .utility)
public init(fileService: CastFileService, configuration: SlideConfiguration = .tvOptimized) {
self.fileService = fileService
self.configuration = configuration
}
deinit {
stop()
}
// MARK: - Enhanced Controls
public func pause() {
logger.info("Pausing slideshow")
slideTimer?.invalidate()
slideTimer = nil
Task { @MainActor in
isPaused = true
isPlaying = false
}
}
public func resume() {
guard !currentFiles.isEmpty else { return }
logger.info("Resuming slideshow")
Task { @MainActor in
isPaused = false
isPlaying = true
}
startSlideTimer()
}
public func togglePlayPause() {
if isPaused || !isPlaying {
resume()
} else {
pause()
}
}
// MARK: - Slideshow Control
public func start(castToken: String) async {
logger.info("Starting slideshow")
self.castToken = castToken
await MainActor.run {
isLoading = true
error = nil
isPlaying = false
isPaused = false
slideLoadingProgress = 0.0
currentSlideIndex = 0
}
stop() // Stop any existing slideshow
slideshowTask = Task {
await runSlideshow()
}
}
public func stop() {
logger.info("Stopping slideshow")
slideshowTask?.cancel()
slideshowTask = nil
prefetchTask?.cancel()
prefetchTask = nil
slideTimer?.invalidate()
slideTimer = nil
Task { @MainActor in
isPlaying = false
isPaused = false
}
// Clear prefetch cache
prefetchCache.removeAll()
}
private func runSlideshow() async {
do {
// Fetch all files
let allFiles = try await fileService.fetchAllFiles(castToken: castToken)
let eligibleFiles = fileService.filterEligibleFiles(allFiles, configuration: configuration)
guard !eligibleFiles.isEmpty else {
await MainActor.run {
isLoading = false
error = "No media files found in this album"
}
return
}
currentFiles = configuration.shuffle ? fileService.shuffleFiles(eligibleFiles) : eligibleFiles
currentIndex = 0
await MainActor.run {
isLoading = false
totalSlides = currentFiles.count
currentSlideIndex = 0
isPlaying = true
}
// Display the first slide
await displaySlideAtIndex(currentIndex)
// Start prefetching
startPrefetching()
// Start the timer for auto-advancement
startSlideTimer()
} catch {
logger.error("Slideshow error: \(error)")
await MainActor.run {
isLoading = false
self.error = error.localizedDescription
}
}
}
private func displayNextSlide() async {
guard !currentFiles.isEmpty else { return }
let file = currentFiles[currentIndex]
do {
let fileData = try await fileService.downloadFile(
castFile: file,
castToken: castToken,
useThumbnail: configuration.useThumbnails
)
await MainActor.run {
currentFile = file
if file.isVideo {
currentVideoData = fileData
currentImageData = nil
} else {
currentImageData = fileData
currentVideoData = nil
}
error = nil
}
// Move to next file (loop back to start when at end)
currentIndex = (currentIndex + 1) % currentFiles.count
// If we've completed a full cycle, reshuffle if enabled
if currentIndex == 0 && configuration.shuffle {
currentFiles = fileService.shuffleFiles(currentFiles)
}
} catch {
logger.error("Failed to display slide for file \(file.id): \(error)")
// Skip this file and move to the next one
currentIndex = (currentIndex + 1) % currentFiles.count
}
}
// MARK: - Manual Navigation
public func nextSlide() async {
guard !currentFiles.isEmpty else { return }
currentIndex = (currentIndex + 1) % currentFiles.count
await displaySlideAtIndex(currentIndex, updateTimer: true)
}
public func previousSlide() async {
guard !currentFiles.isEmpty else { return }
currentIndex = currentIndex > 0 ? currentIndex - 1 : currentFiles.count - 1
await displaySlideAtIndex(currentIndex, updateTimer: true)
}
// MARK: - Timer Management
private func startSlideTimer() {
guard let currentFile = currentFile else { return }
slideTimer?.invalidate()
let duration = configuration.duration(for: currentFile)
slideTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self, self.isPlaying, !self.isPaused else { return }
await self.advanceToNextSlide()
}
}
}
private func advanceToNextSlide() async {
guard !currentFiles.isEmpty else { return }
currentIndex = (currentIndex + 1) % currentFiles.count
// If we've completed a full cycle, reshuffle if enabled
if currentIndex == 0 && configuration.shuffle {
currentFiles = fileService.shuffleFiles(currentFiles)
}
await displaySlideAtIndex(currentIndex)
if isPlaying && !isPaused {
startSlideTimer()
}
}
// MARK: - Enhanced Display Logic
private func displaySlideAtIndex(_ index: Int, updateTimer: Bool = false) async {
guard index >= 0, index < currentFiles.count else { return }
let file = currentFiles[index]
do {
// Update loading state
await MainActor.run {
slideLoadingProgress = 0.0
currentSlideIndex = index
}
// Check if we have prefetched data
let fileData: Data
if let cachedData = prefetchCache[index] {
fileData = cachedData
await MainActor.run { slideLoadingProgress = 1.0 }
} else {
fileData = try await fileService.downloadFile(
castFile: file,
castToken: castToken,
useThumbnail: configuration.useThumbnails
)
await MainActor.run { slideLoadingProgress = 1.0 }
}
await MainActor.run {
currentFile = file
if file.isVideo {
currentVideoData = fileData
currentImageData = nil
} else {
currentImageData = fileData
currentVideoData = nil
}
error = nil
}
// Start/restart timer if needed
if updateTimer && isPlaying && !isPaused {
startSlideTimer()
}
} catch {
logger.error("Failed to display slide for file \(file.id): \(error)")
await MainActor.run {
self.error = "Failed to load image: \(error.localizedDescription)"
slideLoadingProgress = 0.0
}
}
}
// MARK: - Prefetching
private func startPrefetching() {
prefetchTask?.cancel()
prefetchTask = Task {
await performPrefetching()
}
}
private func performPrefetching() async {
let prefetchCount = min(3, currentFiles.count)
for i in 1...prefetchCount {
let prefetchIndex = (currentIndex + i) % currentFiles.count
// Skip if already cached
if prefetchCache[prefetchIndex] != nil { continue }
let file = currentFiles[prefetchIndex]
do {
let fileData = try await fileService.downloadFile(
castFile: file,
castToken: castToken,
useThumbnail: configuration.useThumbnails
)
// Store in cache
prefetchCache[prefetchIndex] = fileData
// Clean up old cache entries (keep only 5 recent entries)
if prefetchCache.count > 5 {
let oldKeys = Array(prefetchCache.keys.sorted().prefix(prefetchCache.count - 5))
for key in oldKeys {
prefetchCache.removeValue(forKey: key)
}
}
} catch {
// Silently fail for prefetching - we'll try again when needed
continue
}
// Add delay between prefetch operations to avoid overwhelming the system
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
}
}

View File

@@ -0,0 +1,125 @@
// Temporary stub implementations for Ente dependencies
// TODO: Remove when actual Ente packages are integrated
import Foundation
// MARK: - EnteCore Stubs
public typealias FileID = Int
public typealias CollectionID = Int
// MARK: - EnteNetwork Stubs
public protocol CastGateway {
func getDiff(sinceTime: Int64) async throws -> DiffResponse
func getFile(fileID: FileID) async throws -> Data
func getThumbnail(fileID: FileID) async throws -> Data
func registerDevice() async throws -> DeviceRegistrationResponse
func getCastData(deviceCode: String) async throws -> CastData
}
public struct DiffResponse: Codable {
public let diff: [FileChange]
public let hasMore: Bool
public init(diff: [FileChange], hasMore: Bool) {
self.diff = diff
self.hasMore = hasMore
}
}
public struct FileChange: Codable {
public let id: FileID
public let updationTime: Int64
public let isDeleted: Bool
public init(id: FileID, updationTime: Int64, isDeleted: Bool) {
self.id = id
self.updationTime = updationTime
self.isDeleted = isDeleted
}
}
public struct DeviceRegistrationResponse: Codable {
public let code: String
public init(code: String) {
self.code = code
}
}
public struct CastData: Codable {
public let collectionID: Int
public let castToken: String
public init(collectionID: Int, castToken: String) {
self.collectionID = collectionID
self.castToken = castToken
}
}
public enum EnteError: Error {
case serverError(Int, String)
case configurationError(String)
}
// Mock implementation of CastGateway
public class MockCastGateway: CastGateway {
public init() {}
public func getDiff(sinceTime: Int64) async throws -> DiffResponse {
// Return empty diff for now
return DiffResponse(diff: [], hasMore: false)
}
public func getFile(fileID: FileID) async throws -> Data {
// Return mock file data
return "Mock file data".data(using: .utf8) ?? Data()
}
public func getThumbnail(fileID: FileID) async throws -> Data {
// Return mock thumbnail data
return "Mock thumbnail data".data(using: .utf8) ?? Data()
}
public func registerDevice() async throws -> DeviceRegistrationResponse {
// Generate a mock device code
let code = String((1...6).map { _ in String(Int.random(in: 0...9)) }.joined())
return DeviceRegistrationResponse(code: code)
}
public func getCastData(deviceCode: String) async throws -> CastData {
// Mock implementation - throw 404 for first few calls, then return data
throw EnteError.serverError(404, "Not found")
}
}
// MARK: - EnteCrypto Implementation (using proper stream decryption)
import EnteCrypto
public enum CryptoUtil {
public static func decryptChaChaBase64(data: Data, key: Data, header: Data) throws -> Data {
// Use proper stream decryption from EnteCrypto
return try EnteCrypto.decryptFileStream(encryptedData: data, key: key, header: header)
}
/// Decrypts file data using stream decryption with base64 encoded parameters
/// This matches the format used by the mobile app and server
public static func decryptFileData(
encryptedData: Data,
base64Key: String,
base64Header: String
) throws -> Data {
guard let keyData = Data(base64Encoded: base64Key),
let headerData = Data(base64Encoded: base64Header) else {
throw CastError.decryptionError("Invalid base64 key or header")
}
return try EnteCrypto.decryptFileStream(
encryptedData: encryptedData,
key: keyData,
header: headerData
)
}
}

View File

@@ -0,0 +1,124 @@
import Foundation
import Logging
public class FileDownloader {
private let logger = Logger(label: "FileDownloader")
private let urlSession: URLSession
private let cache = NSCache<NSString, NSData>()
public init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
setupCache()
}
private func setupCache() {
cache.countLimit = 10 // Keep up to 10 images in memory
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB total cache size
}
// MARK: - Download with Caching
public func downloadWithCache(
url: URL,
cacheKey: String? = nil
) async throws -> Data {
let key = cacheKey ?? url.absoluteString
let cacheKey = NSString(string: key)
// Check cache first
if let cachedData = cache.object(forKey: cacheKey) {
logger.debug("Cache hit for key: \(key)")
return cachedData as Data
}
// Download if not cached
logger.debug("Cache miss, downloading: \(url)")
let (data, response) = try await urlSession.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw FileDownloaderError.downloadFailed
}
// Cache the result
let cost = data.count
cache.setObject(NSData(data: data), forKey: cacheKey, cost: cost)
return data
}
// MARK: - Background Download
public func downloadInBackground(url: URL) -> AsyncStream<DownloadProgress> {
AsyncStream { continuation in
let task = urlSession.downloadTask(with: url) { localURL, response, error in
if let error = error {
continuation.yield(.failed(error))
continuation.finish()
return
}
guard let localURL = localURL,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
continuation.yield(.failed(FileDownloaderError.downloadFailed))
continuation.finish()
return
}
do {
let data = try Data(contentsOf: localURL)
continuation.yield(.completed(data))
continuation.finish()
} catch {
continuation.yield(.failed(error))
continuation.finish()
}
}
task.resume()
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
// MARK: - Cache Management
public func clearCache() {
logger.info("Clearing download cache")
cache.removeAllObjects()
}
public func removeCachedData(for key: String) {
cache.removeObject(forKey: NSString(string: key))
}
}
// MARK: - Download Progress
public enum DownloadProgress {
case progress(ByteCountFormatter.CountStyle)
case completed(Data)
case failed(Error)
}
// MARK: - Errors
public enum FileDownloaderError: Error, LocalizedError {
case downloadFailed
case invalidResponse
case cacheError
public var errorDescription: String? {
switch self {
case .downloadFailed:
return "Download failed"
case .invalidResponse:
return "Invalid server response"
case .cacheError:
return "Cache operation failed"
}
}
}

View File

@@ -0,0 +1,167 @@
import Foundation
import UniformTypeIdentifiers
import ImageIO
import CoreGraphics
#if canImport(UIKit)
import UIKit
#endif
public struct ImageProcessor {
// MARK: - MIME Type Detection
public static func detectMIMEType(from data: Data, fileName: String = "") -> String? {
// Try to detect from data first
if let mimeType = detectMIMETypeFromData(data) {
return mimeType
}
// Fallback to file extension
return detectMIMETypeFromExtension(fileName)
}
private static func detectMIMETypeFromData(_ data: Data) -> String? {
guard data.count >= 12 else { return nil }
let bytes = data.prefix(12)
// Check for common image formats
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
return "image/jpeg"
}
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
return "image/png"
}
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) {
return "image/gif"
}
if bytes.starts(with: [0x57, 0x45, 0x42, 0x50]) {
return "image/webp"
}
// HEIF/HEIC detection
if bytes.count >= 12 {
let slice = bytes[4..<12]
if slice.starts(with: [0x66, 0x74, 0x79, 0x70]) { // "ftyp"
let subtype = bytes.suffix(4)
if subtype.starts(with: [0x68, 0x65, 0x69, 0x63]) { // "heic"
return "image/heic"
}
if subtype.starts(with: [0x68, 0x65, 0x69, 0x66]) { // "heif"
return "image/heif"
}
}
}
return nil
}
private static func detectMIMETypeFromExtension(_ fileName: String) -> String? {
let url = URL(fileURLWithPath: fileName)
let pathExtension = url.pathExtension.lowercased()
switch pathExtension {
case "jpg", "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
default:
return nil
}
}
// MARK: - Image Processing
public static func processImage(_ data: Data, fileName: String) async throws -> Data {
guard let mimeType = detectMIMEType(from: data, fileName: fileName) else {
throw ImageProcessorError.unsupportedFormat
}
// Handle HEIC/HEIF conversion
if mimeType == "image/heic" || mimeType == "image/heif" {
return try await convertHEICToJPEG(data)
}
// For other formats, return as-is for now
return data
}
// MARK: - HEIC Conversion
private static func convertHEICToJPEG(_ data: Data) async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let convertedData = try convertHEICToJPEGSync(data)
continuation.resume(returning: convertedData)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func convertHEICToJPEGSync(_ data: Data) throws -> Data {
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
throw ImageProcessorError.conversionFailed
}
let mutableData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
mutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else {
throw ImageProcessorError.conversionFailed
}
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: 0.8
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw ImageProcessorError.conversionFailed
}
return mutableData as Data
}
// MARK: - Live Photo Processing
public static func extractImageFromLivePhoto(_ data: Data, fileName: String) throws -> (Data, String) {
// Live photo processing is complex and would require parsing the container format
// For now, return the data as-is and let the system handle it
return (data, fileName)
}
}
// MARK: - Errors
public enum ImageProcessorError: Error, LocalizedError {
case unsupportedFormat
case conversionFailed
case processingFailed
public var errorDescription: String? {
switch self {
case .unsupportedFormat:
return "Unsupported image format"
case .conversionFailed:
return "Failed to convert image format"
case .processingFailed:
return "Failed to process image"
}
}
}

View File

@@ -0,0 +1,56 @@
import XCTest
@testable import EnteCast
final class EnteCastTests: XCTestCase {
func testCastFileEligibility() {
let imageFile = CastFile(
id: FileID(1),
metadata: CastFileMetadata(fileType: 0, title: "image.jpg"), // Image type
info: CastFileInfo(fileSize: 1024 * 1024), // 1MB
file: CastFileData(decryptionHeader: "header"),
thumbnail: CastFileData(decryptionHeader: "thumb-header"),
key: "key",
updationTime: 123456,
isDeleted: false
)
XCTAssertTrue(imageFile.isImage)
XCTAssertTrue(imageFile.isEligibleForSlideshow)
let largeFile = CastFile(
id: FileID(2),
metadata: CastFileMetadata(fileType: 0, title: "large.jpg"),
info: CastFileInfo(fileSize: 200 * 1024 * 1024), // 200MB - too large
file: CastFileData(decryptionHeader: "header"),
thumbnail: CastFileData(decryptionHeader: "thumb-header"),
key: "key",
updationTime: 123456,
isDeleted: false
)
XCTAssertFalse(largeFile.isEligibleForSlideshow)
}
func testSlideConfiguration() {
let defaultConfig = SlideConfiguration.default
XCTAssertEqual(defaultConfig.duration, 12.0)
XCTAssertTrue(defaultConfig.shuffle)
let tvConfig = SlideConfiguration.tvOptimized
XCTAssertTrue(tvConfig.useThumbnails)
XCTAssertEqual(tvConfig.maxFileSize, 50 * 1024 * 1024)
}
func testCastPayload() {
let payload = CastPayload(
collectionID: CollectionID(123),
collectionKey: "test-key",
castToken: "test-token"
)
XCTAssertEqual(payload.collectionID, CollectionID(123))
XCTAssertEqual(payload.collectionKey, "test-key")
XCTAssertEqual(payload.castToken, "test-token")
}
}

View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged.git",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
}
],
"version" : 2
}

Some files were not shown because too many files have changed in this diff Show More