feat(ios): notify when a show gains episodes

A BGProcessingTask runs the same crawl_for_news no sooner than every six
hours, when iOS chooses. A processing task rather than an app refresh,
since a crawl of a large share does not fit in thirty seconds.
This commit is contained in:
nvrl committed 2026-10-13 21:26:15 +02:00
1 parent 4b9cbd26bc
commit d4e9628199
6 files changed
+114 -1

No files matched your search

+6
View File
@@ -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/*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/*` | | `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
| `download/*` | `Download/*` | | `download/*` | `Download/*` |
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.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` | | `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. 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 - **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it. 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 - **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 `.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`) a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
+82
View File
@@ -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"
}
}
}
@@ -33,6 +33,13 @@ struct ProtonStreamApp: App {
} }
final class AppDelegate: NSObject, UIApplicationDelegate { 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 { func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask } MainActor.assumeIsolated { OrientationLock.mask }
} }
+7
View File
@@ -72,6 +72,12 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) } 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] static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity] private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
@@ -88,5 +94,6 @@ struct SettingsStore {
static let libraryFilter = "library_filter" static let libraryFilter = "library_filter"
static let libraryGrouped = "library_grouped" static let libraryGrouped = "library_grouped"
static let metadataOfferDismissed = "metadata_offer_dismissed" static let metadataOfferDismissed = "metadata_offer_dismissed"
static let newEpisodeNotifications = "new_episode_notifications"
} }
} }
+9
View File
@@ -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: "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: "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 { @ViewBuilder private var storage: some View {
@@ -264,12 +265,20 @@ private final class SettingsValues {
private let store = SettingsStore() private let store = SettingsStore()
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } } var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } } 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 hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } } var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
init() { init() {
wifiOnly = store.wifiOnly wifiOnly = store.wifiOnly
backgroundAudio = store.backgroundAudio backgroundAudio = store.backgroundAudio
newEpisodeNotifications = store.newEpisodeNotifications
hardwareDecoding = store.hardwareDecoding hardwareDecoding = store.hardwareDecoding
cacheBudgetGib = store.cacheBudgetGib cacheBudgetGib = store.cacheBudgetGib
} }
+3 -1
View File
@@ -64,7 +64,9 @@ targets:
CFBundleDisplayName: Proton Stream CFBundleDisplayName: Proton Stream
LSRequiresIPhoneOS: true LSRequiresIPhoneOS: true
ITSAppUsesNonExemptEncryption: false 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. # The Proton palette's page colour, so a dark app does not open on white.
UILaunchScreen: UILaunchScreen:
UIColorName: LaunchBackground UIColorName: LaunchBackground