6
mobile/native/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
**/.build/
|
||||
.DS_Store
|
||||
# Xcode user settings
|
||||
xcuserdata/
|
||||
|
||||
.swiftpm/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Back.png",
|
||||
"idiom" : "tv"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"layers" : [
|
||||
{
|
||||
"filename" : "Middle.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Back.imagestacklayer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Middle.png",
|
||||
"idiom" : "tv"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"layers" : [
|
||||
{
|
||||
"filename" : "Front.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Middle.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Back.imagestacklayer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/ente-photos-icon-transparent.png
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
21
mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/ducky.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
23
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera.png
vendored
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 626 KiB |
23
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv.png
vendored
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 815 KiB |
79
mobile/native/ios/Apps/tv/cast/ContentView.swift
Normal 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()
|
||||
}
|
||||
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Black.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BlackItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Bold.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BoldItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Extrabold.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ExtraboldItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Heavy.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-HeavyItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Light.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-LightItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Medium.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-MediumItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Regular.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-RegularItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Semibold.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-SemiboldItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Thin.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ThinItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLight.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLightItalic.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Inter-Bold.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Inter-Light.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Inter-Medium.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Inter-Regular.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Inter-SemiBold.ttf
Normal file
BIN
mobile/native/ios/Apps/tv/cast/Fonts/Montserrat-Bold.ttf
Normal file
17
mobile/native/ios/Apps/tv/cast/Info.plist
Normal 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>
|
||||
166
mobile/native/ios/Apps/tv/cast/Models/CastModels.swift
Normal 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
|
||||
}
|
||||
252
mobile/native/ios/Apps/tv/cast/Services/CastPairingService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
189
mobile/native/ios/Apps/tv/cast/Services/FileCache.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
mobile/native/ios/Apps/tv/cast/Utils/FontUtils.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
398
mobile/native/ios/Apps/tv/cast/ViewModels/CastViewModel.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
1412
mobile/native/ios/Apps/tv/cast/ViewModels/SlideshowService.swift
Normal file
31
mobile/native/ios/Apps/tv/cast/Views/EnteBranding.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
182
mobile/native/ios/Apps/tv/cast/Views/PairingView.swift
Normal 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")
|
||||
}
|
||||
677
mobile/native/ios/Apps/tv/cast/Views/SlideshowView.swift
Normal 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()
|
||||
}
|
||||
149
mobile/native/ios/Apps/tv/cast/Views/StatusView.swift
Normal 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)
|
||||
|
||||
}
|
||||
332
mobile/native/ios/Apps/tv/cast/Views/VideoPlayerView.swift
Normal 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")
|
||||
}
|
||||
10
mobile/native/ios/Apps/tv/cast/tvApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct tvApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
635
mobile/native/ios/Apps/tv/tv.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
14
mobile/native/ios/Packages/EnteCast/Package.resolved
Normal 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
|
||||
}
|
||||
40
mobile/native/ios/Packages/EnteCast/Package.swift
Normal 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
8
mobile/native/ios/Packages/EnteCore/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
23
mobile/native/ios/Packages/EnteCore/Package.resolved
Normal 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
|
||||
}
|
||||