diff --git a/docs/IOS.md b/docs/IOS.md index 40490cf..4dd71ab 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -16,6 +16,7 @@ file of the same name is the one to change with it. | `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` | | `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` | | `download/*` | `Download/*` | +| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` | | `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` | | `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` | @@ -79,6 +80,11 @@ Each of these is the platform's doing, not a choice to diverge. place of Android's `WatchSyncWorker`, and every five minutes while it is open. - **Background audio off pauses** the episode when the app leaves the screen, where Android stops it. +- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker` + crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no + sooner than six hours, and the system decides when, mostly overnight on a + charger. An app refresh would come more often but gets about thirty seconds, + which a crawl of a large share does not fit in. - **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's `.plain` button style and `onTapGesture` show none, which on a slow iPad reads as a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`) diff --git a/ios/ProtonStream/App/NewEpisodes.swift b/ios/ProtonStream/App/NewEpisodes.swift new file mode 100644 index 0000000..694a51e --- /dev/null +++ b/ios/ProtonStream/App/NewEpisodes.swift @@ -0,0 +1,82 @@ +import BackgroundTasks +import Foundation +import UserNotifications + +/// Look at the shares while the app is away, and say when a show gained +/// episodes — what `NewEpisodesWorker` does on Android. +/// +/// A processing task rather than an app refresh: a crawl of a large share +/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it +/// when it sees fit, mostly overnight on a charger, so it is a best effort +/// where Android's is a schedule. +enum NewEpisodes { + static let identifier = "io.narl.protonstream.new-episodes" + private static let interval: TimeInterval = 6 * 60 * 60 + + /// At launch, before the app finishes launching: iOS refuses a handler + /// registered later. + static func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in + guard let task = task as? BGProcessingTask else { return } + run(task) + } + } + + /// Keep the next look scheduled, or cancel it when the viewer turned it off. + static func schedule() { + guard SettingsStore().newEpisodeNotifications else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier) + return + } + let request = BGProcessingTaskRequest(identifier: identifier) + request.requiresNetworkConnectivity = true + request.earliestBeginDate = Date(timeIntervalSinceNow: interval) + try? BGTaskScheduler.shared.submit(request) + } + + /// Ask once, when the viewer has the setting on; iOS remembers the answer. + static func requestPermission() { + guard SettingsStore().newEpisodeNotifications else { return } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + private static func run(_ task: BGProcessingTask) { + schedule() + let work = Task { + let news = (try? await NativeRuntime.engine().crawlForNews()) ?? [] + await post(news) + task.setTaskCompleted(success: !Task.isCancelled) + } + task.expirationHandler = { + work.cancel() + Task { try? await NativeRuntime.engine().stopCrawl() } + } + } + + private static func post(_ news: [NewEpisodesRecord]) async { + let center = UNUserNotificationCenter.current() + guard await center.notificationSettings().authorizationStatus == .authorized else { return } + for item in news { + let content = UNMutableNotificationContent() + content.title = item.title + content.body = describe(item) + content.threadIdentifier = "new-episodes" + // Keyed by the show, so a second look's news replaces the first. + let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil) + try? await center.add(request) + } + } + + /// What one show's notification says: "S01E04 and 2 more are new". + static func describe(_ news: NewEpisodesRecord) -> String { + let more = Int(news.count) - 1 + guard let first = news.first else { + return news.count == 1 ? "1 new episode" : "\(news.count) new episodes" + } + switch more { + case 0: return "\(first) is new" + case 1: return "\(first) and 1 more are new" + default: return "\(first) and \(more) more are new" + } + } +} diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 6c8513a..4b0a92f 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -33,6 +33,13 @@ struct ProtonStreamApp: App { } final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + NewEpisodes.register() + NewEpisodes.schedule() + NewEpisodes.requestPermission() + return true + } + func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { MainActor.assumeIsolated { OrientationLock.mask } } diff --git a/ios/ProtonStream/App/SettingsStore.swift b/ios/ProtonStream/App/SettingsStore.swift index 296aa33..5a51741 100644 --- a/ios/ProtonStream/App/SettingsStore.swift +++ b/ios/ProtonStream/App/SettingsStore.swift @@ -72,6 +72,12 @@ struct SettingsStore { nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) } } + /// Whether a background crawl may say that a show gained episodes. + var newEpisodeNotifications: Bool { + get { bool(Key.newEpisodeNotifications, default: true) } + nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) } + } + static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded] private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity] @@ -88,5 +94,6 @@ struct SettingsStore { static let libraryFilter = "library_filter" static let libraryGrouped = "library_grouped" static let metadataOfferDismissed = "metadata_offer_dismissed" + static let newEpisodeNotifications = "new_episode_notifications" } } diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 1d33c1b..25f27d8 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -162,6 +162,7 @@ struct SettingsView: View { } SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding) SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio) + SwitchRow(headline: "New episode notifications", supporting: "Looks at your shares now and then and says when a show gains episodes", isOn: $settings.newEpisodeNotifications) } @ViewBuilder private var storage: some View { @@ -264,12 +265,20 @@ private final class SettingsValues { private let store = SettingsStore() var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } } var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } } + var newEpisodeNotifications: Bool { + didSet { + store.newEpisodeNotifications = newEpisodeNotifications + NewEpisodes.schedule() + NewEpisodes.requestPermission() + } + } var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } } var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } } init() { wifiOnly = store.wifiOnly backgroundAudio = store.backgroundAudio + newEpisodeNotifications = store.newEpisodeNotifications hardwareDecoding = store.hardwareDecoding cacheBudgetGib = store.cacheBudgetGib } diff --git a/ios/project.yml b/ios/project.yml index 707e79e..3caba03 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -64,7 +64,9 @@ targets: CFBundleDisplayName: Proton Stream LSRequiresIPhoneOS: true ITSAppUsesNonExemptEncryption: false - UIBackgroundModes: [audio] + UIBackgroundModes: [audio, processing] + BGTaskSchedulerPermittedIdentifiers: + - io.narl.protonstream.new-episodes # The Proton palette's page colour, so a dark app does not open on white. UILaunchScreen: UIColorName: LaunchBackground