From b4014c922eaae11186334109417cf6a40b9e494c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 3 Sep 2025 21:50:25 +0200 Subject: [PATCH] feat: native download notifications (#1006) --- app.json | 4 +- app/_layout.tsx | 9 +++ eas.json | 19 +++---- providers/DownloadProvider.tsx | 100 ++++++++++++++++++++++++++++++++- providers/JellyfinProvider.tsx | 4 +- 5 files changed, 120 insertions(+), 16 deletions(-) diff --git a/app.json b/app.json index c9382a30..59a58f31 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.35.0", + "version": "0.35.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -37,7 +37,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 66, + "versionCode": 67, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/app/_layout.tsx b/app/_layout.tsx index 29f2c89b..e1f3149a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -269,6 +269,15 @@ function Layout() { await Notifications?.setNotificationChannelAsync("default", { name: "default", }); + + // Create dedicated channel for download notifications + console.log("Setting android notification channel 'downloads'"); + await Notifications?.setNotificationChannelAsync("downloads", { + name: "Downloads", + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); } const granted = await checkAndRequestPermissions(); diff --git a/eas.json b/eas.json index a9aef4c6..17ff8edd 100644 --- a/eas.json +++ b/eas.json @@ -26,13 +26,6 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, - "preview": { - "environment": "development", - "distribution": "internal", - "env": { - "EXPO_PUBLIC_WRITE_DEBUG": "1" - } - }, "development-simulator": { "environment": "development", "developmentClient": true, @@ -44,16 +37,22 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, + "preview": { + "distribution": "internal", + "env": { + "EXPO_PUBLIC_WRITE_DEBUG": "1" + } + }, "production": { "environment": "production", - "channel": "0.35.0", + "channel": "0.35.1", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.35.0", + "channel": "0.35.1", "android": { "buildType": "apk", "image": "latest" @@ -61,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.35.0", + "channel": "0.35.1", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 7a8fe7d1..c3180c0a 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -4,6 +4,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import * as Application from "expo-application"; import * as FileSystem from "expo-file-system"; +import * as Notifications from "expo-notifications"; import { router } from "expo-router"; import { atom, useAtom } from "jotai"; import { throttle } from "lodash"; @@ -90,6 +91,69 @@ function useDownloadProvider() { const { settings } = useSettings(); const successHapticFeedback = useHaptic("success"); + // Generate notification content based on item type + const getNotificationContent = useCallback( + (item: BaseItemDto, isSuccess: boolean) => { + if (item.Type === "Episode") { + const season = item.ParentIndexNumber + ? String(item.ParentIndexNumber).padStart(2, "0") + : "??"; + const episode = item.IndexNumber + ? String(item.IndexNumber).padStart(2, "0") + : "??"; + const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } else if (item.Type === "Movie") { + const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; + const subtitle = `${item.Name}${year}`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } else { + // Fallback for other types + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed_for_item", { + item: item.Name, + }) + : t("home.downloads.toasts.download_failed_for_item", { + item: item.Name, + }), + body: item.Name || "Unknown item", + }; + } + }, + [t], + ); + + // Send local notification for download events + const sendDownloadNotification = useCallback( + async (title: string, body: string, data?: Record) => { + if (Platform.isTV) return; + + try { + await Notifications.scheduleNotificationAsync({ + content: { + title, + body, + data, + ...(Platform.OS === "android" && { channelId: "downloads" }), + }, + trigger: null, // Show immediately + }); + } catch (error) { + console.error("Failed to send notification:", error); + } + }, + [], + ); + /// Cant use the background downloader callback. As its not triggered if size is unknown. const updateProgress = async () => { const tasks = await BackGroundDownloader.checkForExistingDownloads(); @@ -418,6 +482,21 @@ function useDownloadProvider() { } await saveDownloadsDatabase(db); + // Send native notification for successful download + const successNotification = getNotificationContent( + process.item, + true, + ); + await sendDownloadNotification( + successNotification.title, + successNotification.body, + { + itemId: process.item.Id, + itemName: process.item.Name, + type: "download_completed", + }, + ); + toast.success( t("home.downloads.toasts.download_completed_for_item", { item: process.item.Name, @@ -425,8 +504,25 @@ function useDownloadProvider() { ); removeProcess(process.id); }) - .error((error: any) => { + .error(async (error: any) => { console.error("Download error:", error); + + // Send native notification for failed download + const failureNotification = getNotificationContent( + process.item, + false, + ); + await sendDownloadNotification( + failureNotification.title, + failureNotification.body, + { + itemId: process.item.Id, + itemName: process.item.Name, + type: "download_failed", + error: error?.message || "Unknown error", + }, + ); + toast.error( t("home.downloads.toasts.download_failed_for_item", { item: process.item.Name, @@ -435,7 +531,7 @@ function useDownloadProvider() { removeProcess(process.id); }); }, - [authHeader], + [authHeader, sendDownloadNotification, getNotificationContent], ); const manageDownloadQueue = useCallback(() => { diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 56f64abd..1ca64814 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.35.0" }, + clientInfo: { name: "Streamyfin", version: "0.35.1" }, deviceInfo: { name: deviceName, id, @@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.35.0"`, + }, DeviceId="${deviceId}", Version="0.35.1"`, }; }, [deviceId]);