Skip to content

Commit 95bf356

Browse files
committed
feat(companion): mirror episode context
1 parent 600d32b commit 95bf356

22 files changed

Lines changed: 307 additions & 48 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The two native mobile apps keep platform-specific UI and storage while sharing t
3535
- **Connect TMDb safely** through browser approval or TV QR without entering a password in SmartMovie. Browse paginated Movie/TV account recommendations, rate Movie/TV/Episode titles, and manage account library/lists with durable offline mutation retry.
3636
- **Manage mixed custom lists** by loading every list page, editing metadata, paging through Movie/TV contents, searching the catalog, and adding or removing titles with restart-safe optimistic synchronization.
3737
- **Build a local-first library** with independent Favorite and Watchlist actions. SwiftData keeps both readable offline; private CloudKit remains an Apple storage option.
38-
- **Move between Apple devices naturally** with five adaptive destinations, keyboard/pointer support, focus-driven TV navigation, multi-window Mac/visionOS details, and a safe Apple Watch companion remote.
38+
- **Move between Apple devices naturally** with five adaptive destinations, keyboard/pointer support, focus-driven TV navigation, multi-window Mac/visionOS details, and an Apple Watch companion that mirrors a safe title or exact episode and opens it back on iPhone.
3939
- **Use the app in six languages**: English, Vietnamese, Japanese, Korean, Simplified Chinese, and Traditional Chinese.
4040
- **Rely on accessible defaults** including Dynamic Type, VoiceOver labels, Increase Contrast, Reduce Motion, and platform-native focus behavior.
4141

@@ -67,7 +67,7 @@ The universal app expands shelves and content density on iPad while retaining th
6767
<table>
6868
<tr>
6969
<td width="72%" align="center"><strong>Apple TV</strong><br><sub>10-foot catalog with focus and Siri Remote navigation</sub></td>
70-
<td width="28%" align="center"><strong>Apple Watch</strong><br><sub>Remote for the title open on the paired iPhone</sub></td>
70+
<td width="28%" align="center"><strong>Apple Watch</strong><br><sub>Safe title/episode context with exact-detail iPhone handoff</sub></td>
7171
</tr>
7272
<tr>
7373
<td align="center"><img src="docs/images/screenshots/apple-tv-home.png" alt="SmartMovie Home on Apple TV" width="760"></td>
@@ -86,7 +86,7 @@ Screenshots come from the real SwiftUI targets using deterministic local fixture
8686
| Mac Catalyst | macOS compatible with the iOS 17 target | Shared universal app with expanded navigation | `SmartMovie` |
8787
| Apple TV | tvOS 17 | 10-foot layout, focus-driven shelves, Siri Remote/D-pad navigation, and trailer handoff | `SmartMovieTV` |
8888
| Apple Vision Pro | visionOS 1 | Resizable catalog window and separate title-detail windows | `SmartMovieVision` |
89-
| Apple Watch | watchOS 10 | Non-standalone WatchConnectivity companion remote | `SmartMovieWatch` |
89+
| Apple Watch | watchOS 10 | Non-standalone WatchConnectivity companion for safe title/episode context and phone handoff | `SmartMovieWatch` |
9090
| Native Mac | macOS 14 | `NavigationSplitView`, menu commands, keyboard shortcuts, and multi-window details | `SmartMovieNativeMac` |
9191

9292
The universal Apple product uses bundle ID `LamNDT.SmartMovie`, the embedded watch companion uses `LamNDT.SmartMovie.watchkitapp`, and the native Mac product uses `LamNDT.SmartMovie.NativeMac`. The catalog products share the private CloudKit container `iCloud.LamNDT.SmartMovie`.
@@ -203,7 +203,7 @@ cd ../..
203203
./scripts/verify-release.sh
204204
```
205205

206-
The current verified local baseline contains 60 Swift tests and 92 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, typed regional release/content-rating, Movie/TV alternative-title and translation metadata, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, explicit adult age confirmation, six-digit PIN validation, five-attempt lockout, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
206+
The current verified local baseline contains 61 Swift tests and 92 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, typed regional release/content-rating, Movie/TV alternative-title and translation metadata, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, explicit adult age confirmation, six-digit PIN validation, five-attempt lockout, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, exact episode companion context, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
207207

208208
CI independently builds and analyzes iOS, iPad/Catalyst, tvOS, native macOS, watchOS, and visionOS, then installs and launches the iOS app in Simulator. Read [Testing](docs/TESTING.md) for destination-specific commands and the manual device matrix.
209209

SmartMovie/Apps/Universal/PhoneWatchRemoteController.swift

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,23 @@ final class PhoneWatchRemoteController: NSObject, WatchRemoteSession {
1212
private struct RemoteCommand: Sendable {
1313
let action: WatchRemoteAction
1414
let libraryKey: String
15+
let contextKey: String?
1516
}
1617

1718
private enum Key {
1819
static let action = "action"
1920
static let artworkURL = "artworkURL"
21+
static let cleared = "cleared"
22+
static let contextKey = "contextKey"
23+
static let contextKind = "contextKind"
24+
static let episodeNumber = "episodeNumber"
2025
static let favorite = "favorite"
2126
static let hasTrailer = "hasTrailer"
2227
static let libraryKey = "libraryKey"
28+
static let libraryActionsAvailable = "libraryActionsAvailable"
2329
static let mediaType = "mediaType"
30+
static let seasonNumber = "seasonNumber"
31+
static let seriesTitle = "seriesTitle"
2432
static let success = "ok"
2533
static let rating = "rating"
2634
static let title = "title"
@@ -47,29 +55,46 @@ final class PhoneWatchRemoteController: NSObject, WatchRemoteSession {
4755
publishCurrentContext()
4856
}
4957

58+
func clear(contextKey: String) {
59+
guard currentContext?.contextKey == contextKey else { return }
60+
currentContext = nil
61+
publishCurrentContext()
62+
}
63+
5064
private func publishCurrentContext() {
51-
guard let session, session.activationState == .activated, let currentContext else { return }
65+
guard let session, session.activationState == .activated else { return }
66+
let applicationContext = currentContext.map { payload(for: $0) } ?? [Key.cleared: true]
5267
do {
53-
try session.updateApplicationContext(payload(for: currentContext))
68+
try session.updateApplicationContext(applicationContext)
5469
} catch {
5570
// The next detail update or activation callback retries with fresh state.
5671
}
5772
}
5873

5974
private func payload(for context: WatchRemoteContext) -> [String: Any] {
75+
let episode = context.episode
6076
var payload: [String: Any] = [
61-
Key.favorite: context.isFavorite,
62-
Key.hasTrailer: context.hasTrailer,
77+
Key.contextKey: context.contextKey,
78+
Key.contextKind: episode == nil ? "title" : "episode",
79+
Key.favorite: context.supportsLibraryActions && context.isFavorite,
80+
Key.hasTrailer: episode == nil && context.hasTrailer,
6381
Key.libraryKey: context.title.libraryKey,
64-
Key.mediaType: context.title.mediaType.rawValue,
65-
Key.rating: context.title.voteAverage,
66-
Key.title: context.title.displayTitle,
67-
Key.watchlist: context.isWatchlisted
82+
Key.libraryActionsAvailable: context.supportsLibraryActions,
83+
Key.mediaType: episode == nil ? context.title.mediaType.rawValue : "episode",
84+
Key.rating: episode?.voteAverage ?? context.title.voteAverage,
85+
Key.title: episode?.name ?? context.title.displayTitle,
86+
Key.watchlist: context.supportsLibraryActions && context.isWatchlisted
6887
]
6988
if let artworkURL = context.artworkURL?.absoluteString {
7089
payload[Key.artworkURL] = artworkURL
7190
}
72-
if let year = context.title.releaseYear {
91+
if let episode {
92+
payload[Key.episodeNumber] = episode.episodeNumber
93+
payload[Key.seasonNumber] = episode.seasonNumber
94+
payload[Key.seriesTitle] = context.title.displayTitle
95+
}
96+
let year = episode?.airDate.map { String($0.prefix(4)) } ?? context.title.releaseYear
97+
if let year {
7398
payload[Key.year] = year
7499
}
75100
return payload
@@ -79,19 +104,28 @@ final class PhoneWatchRemoteController: NSObject, WatchRemoteSession {
79104
guard
80105
let command,
81106
let context = currentContext,
82-
command.libraryKey == context.title.libraryKey
107+
command.contextKey == context.contextKey || (
108+
command.contextKey == nil && command.libraryKey == context.title.libraryKey
109+
)
83110
else {
84111
return [Key.success: false]
85112
}
86113

87114
switch command.action {
88115
case .openDetails:
89-
coordinator.present(title: context.title, playsTrailer: false)
116+
if let episode = context.episode {
117+
coordinator.presentEpisode(series: context.title, episode: episode)
118+
} else {
119+
coordinator.present(title: context.title, playsTrailer: false)
120+
}
90121
case .playTrailer:
122+
guard context.episode == nil, context.hasTrailer else { return [Key.success: false] }
91123
coordinator.present(title: context.title, playsTrailer: true)
92124
case .toggleFavorite:
125+
guard context.supportsLibraryActions else { return [Key.success: false] }
93126
guard toggle(.favorites, context: context) else { return [Key.success: false] }
94127
case .toggleWatchlist:
128+
guard context.supportsLibraryActions else { return [Key.success: false] }
95129
guard toggle(.watchlist, context: context) else { return [Key.success: false] }
96130
}
97131
return replyPayload()
@@ -102,6 +136,7 @@ final class PhoneWatchRemoteController: NSObject, WatchRemoteSession {
102136
try library.toggle(context.title, in: collection)
103137
let refreshedContext = WatchRemoteContext(
104138
title: context.title,
139+
episode: context.episode,
105140
artworkURL: context.artworkURL,
106141
isFavorite: try library.contains(context.title, in: .favorites),
107142
isWatchlisted: try library.contains(context.title, in: .watchlist),
@@ -162,7 +197,11 @@ extension PhoneWatchRemoteController: WCSessionDelegate {
162197
let action = WatchRemoteAction(rawValue: rawAction),
163198
let libraryKey = message[Key.libraryKey] as? String
164199
else { return nil }
165-
return RemoteCommand(action: action, libraryKey: libraryKey)
200+
return RemoteCommand(
201+
action: action,
202+
libraryKey: libraryKey,
203+
contextKey: message[Key.contextKey] as? String
204+
)
166205
}
167206
}
168207
#endif

SmartMovie/Apps/Watch/Localizable.xcstrings

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"sourceLanguage" : "en",
33
"strings" : {
4+
"EPISODE" : {
5+
"localizations" : {
6+
"ja" : { "stringUnit" : { "state" : "translated", "value" : "エピソード" } },
7+
"ko" : { "stringUnit" : { "state" : "translated", "value" : "에피소드" } },
8+
"vi" : { "stringUnit" : { "state" : "translated", "value" : "TẬP PHIM" } },
9+
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "剧集" } },
10+
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "集數" } }
11+
}
12+
},
413
"Favorite" : {
514
"localizations" : {
615
"ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入り" } },
@@ -55,6 +64,15 @@
5564
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "在 iPhone 上開啟一個影視項目即可開始控制。" } }
5665
}
5766
},
67+
"Open a title or episode on your iPhone to start controlling it." : {
68+
"localizations" : {
69+
"ja" : { "stringUnit" : { "state" : "translated", "value" : "iPhoneで作品またはエピソードを開くと操作を開始できます。" } },
70+
"ko" : { "stringUnit" : { "state" : "translated", "value" : "iPhone에서 작품이나 에피소드를 열어 제어를 시작하세요." } },
71+
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Mở một tựa phim hoặc tập phim trên iPhone để bắt đầu điều khiển." } },
72+
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "在 iPhone 上打开影视条目或剧集即可开始控制。" } },
73+
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "在 iPhone 上開啟影視項目或集數即可開始控制。" } }
74+
}
75+
},
5876
"Open iPhone app" : {
5977
"localizations" : {
6078
"ja" : { "stringUnit" : { "state" : "translated", "value" : "iPhoneアプリを開く" } },

SmartMovie/Apps/Watch/WatchRemoteModel.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import WatchKit
55

66
struct WatchRemoteTitle: Equatable, Sendable {
77
let libraryKey: String
8+
let contextKey: String
9+
let contextKind: String
810
let title: String
911
let mediaType: String
12+
let seriesTitle: String?
13+
let seasonNumber: Int?
14+
let episodeNumber: Int?
1015
let year: String?
1116
let artworkURL: URL?
1217
let rating: Double
1318
let hasTrailer: Bool
19+
let libraryActionsAvailable: Bool
1420
var isFavorite: Bool
1521
var isWatchlisted: Bool
1622
}
@@ -28,10 +34,17 @@ final class WatchRemoteModel: NSObject {
2834
private enum Key {
2935
static let action = "action"
3036
static let artworkURL = "artworkURL"
37+
static let cleared = "cleared"
38+
static let contextKey = "contextKey"
39+
static let contextKind = "contextKind"
40+
static let episodeNumber = "episodeNumber"
3141
static let favorite = "favorite"
3242
static let hasTrailer = "hasTrailer"
3343
static let libraryKey = "libraryKey"
44+
static let libraryActionsAvailable = "libraryActionsAvailable"
3445
static let mediaType = "mediaType"
46+
static let seasonNumber = "seasonNumber"
47+
static let seriesTitle = "seriesTitle"
3548
static let success = "ok"
3649
static let rating = "rating"
3750
static let title = "title"
@@ -80,6 +93,7 @@ final class WatchRemoteModel: NSObject {
8093
}
8194
let message: [String: Any] = [
8295
Key.action: action.rawValue,
96+
Key.contextKey: currentTitle.contextKey,
8397
Key.libraryKey: currentTitle.libraryKey
8498
]
8599
session.sendMessage(message) { [weak self] reply in
@@ -119,12 +133,18 @@ final class WatchRemoteModel: NSObject {
119133

120134
return WatchRemoteTitle(
121135
libraryKey: libraryKey,
136+
contextKey: context[Key.contextKey] as? String ?? libraryKey,
137+
contextKind: context[Key.contextKind] as? String ?? "title",
122138
title: title,
123139
mediaType: mediaType,
140+
seriesTitle: context[Key.seriesTitle] as? String,
141+
seasonNumber: context[Key.seasonNumber] as? Int,
142+
episodeNumber: context[Key.episodeNumber] as? Int,
124143
year: context[Key.year] as? String,
125144
artworkURL: (context[Key.artworkURL] as? String).flatMap(URL.init(string:)),
126145
rating: context[Key.rating] as? Double ?? 0,
127146
hasTrailer: context[Key.hasTrailer] as? Bool ?? false,
147+
libraryActionsAvailable: context[Key.libraryActionsAvailable] as? Bool ?? true,
128148
isFavorite: context[Key.favorite] as? Bool ?? false,
129149
isWatchlisted: context[Key.watchlist] as? Bool ?? false
130150
)
@@ -138,10 +158,14 @@ extension WatchRemoteModel: WCSessionDelegate {
138158
error: (any Error)?
139159
) {
140160
let reachable = activationState == .activated && session.isReachable
141-
let receivedTitle = Self.decode(context: session.receivedApplicationContext)
161+
let applicationContext = session.receivedApplicationContext
162+
let receivedTitle = Self.decode(context: applicationContext)
163+
let shouldClear = applicationContext[Key.cleared] as? Bool == true
142164
Task { @MainActor [weak self] in
143165
self?.isReachable = reachable
144-
if let receivedTitle {
166+
if shouldClear {
167+
self?.currentTitle = nil
168+
} else if let receivedTitle {
145169
self?.currentTitle = receivedTitle
146170
}
147171
}
@@ -156,8 +180,11 @@ extension WatchRemoteModel: WCSessionDelegate {
156180

157181
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
158182
let receivedTitle = Self.decode(context: applicationContext)
183+
let shouldClear = applicationContext[Key.cleared] as? Bool == true
159184
Task { @MainActor [weak self] in
160-
if let receivedTitle {
185+
if shouldClear {
186+
self?.currentTitle = nil
187+
} else if let receivedTitle {
161188
self?.currentTitle = receivedTitle
162189
}
163190
}

0 commit comments

Comments
 (0)