mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
4b9cbd26bc
commit
d4e9628199
6 files changed
+114
-1
No files matched your search
@@ -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`)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user