diff --git a/front/apps/mobile/package.json b/front/apps/mobile/package.json index 4edf80ca..6085325b 100644 --- a/front/apps/mobile/package.json +++ b/front/apps/mobile/package.json @@ -17,6 +17,7 @@ "@formatjs/intl-displaynames": "^6.6.4", "@formatjs/intl-locale": "^3.4.3", "@gorhom/portal": "^1.0.14", + "@kesha-antonov/react-native-background-downloader": "^2.10.0", "@kyoo/ui": "workspace:^", "@material-symbols/svg-400": "^0.14.1", "@shopify/flash-list": "1.4.3", diff --git a/front/packages/models/src/account-internal.ts b/front/packages/models/src/account-internal.ts index 4f1b3cd7..c925c6d3 100644 --- a/front/packages/models/src/account-internal.ts +++ b/front/packages/models/src/account-internal.ts @@ -22,7 +22,7 @@ import { z } from "zod"; import { Account, AccountP } from "./accounts"; import { MMKV } from "react-native-mmkv"; -const storage = new MMKV(); +export const storage = new MMKV(); const readAccounts = () => { const acc = storage.getString("accounts"); diff --git a/front/packages/models/src/query.tsx b/front/packages/models/src/query.tsx index c387ebc1..ca5bd8d9 100644 --- a/front/packages/models/src/query.tsx +++ b/front/packages/models/src/query.tsx @@ -40,7 +40,7 @@ export let kyooApiUrl = kyooUrl; export const queryFn = async ( context: - | (QueryFunctionContext & { timeout?: number }) + | (QueryFunctionContext & { timeout?: number, apiUrl?: string }) | { path: (string | false | undefined | null)[]; body?: object; @@ -52,7 +52,6 @@ export const queryFn = async ( type?: z.ZodType, token?: string | null, ): Promise => { - // @ts-ignore const url = context.apiUrl ?? (Platform.OS === "web" ? kyooUrl : getCurrentAccount()!.apiUrl); kyooApiUrl = url; @@ -166,7 +165,7 @@ export type QueryPage = ComponentType< randomItems?: Items[]; }; -const toQueryKey = (query: QueryIdentifier) => { +export const toQueryKey = (query: QueryIdentifier) => { if (query.params) { return [ ...query.path, diff --git a/front/packages/ui/package.json b/front/packages/ui/package.json index 00ab648e..282d6479 100644 --- a/front/packages/ui/package.json +++ b/front/packages/ui/package.json @@ -14,6 +14,7 @@ "typescript": "^5.3.2" }, "peerDependencies": { + "@kesha-antonov/react-native-background-downloader": "*", "@material-symbols/svg-400": "*", "@shopify/flash-list": "^1.3.1", "@tanstack/react-query": "*", @@ -26,5 +27,13 @@ "react-native-reanimated": "*", "react-native-svg": "*", "yoshiki": "*" + }, + "optionalDependencies": { + "@kesha-antonov/react-native-background-downloader": "^2.10.0" + }, + "peerDependenciesMeta": { + "@kesha-antonov/react-native-background-downloader": { + "optional": true + } } } diff --git a/front/packages/ui/src/downloads/download.tsx b/front/packages/ui/src/downloads/download.tsx new file mode 100644 index 00000000..fffea208 --- /dev/null +++ b/front/packages/ui/src/downloads/download.tsx @@ -0,0 +1,165 @@ +/* + * Kyoo - A portable and vast media library solution. + * Copyright (c) Kyoo. + * + * See AUTHORS.md and LICENSE file in the project root for full license information. + * + * Kyoo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Kyoo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Kyoo. If not, see . + */ + +import { + download, + completeHandler, + directories, + DownloadTask, + checkForExistingDownloads, + Network, + ensureDownloadsAreRunning, +} from "@kesha-antonov/react-native-background-downloader"; +import { + Account, + Episode, + Movie, + QueryIdentifier, + WatchInfo, + queryFn, + toQueryKey, +} from "@kyoo/models"; +import { Player } from "../player"; +import { atom, useSetAtom, useAtom, Atom, PrimitiveAtom, useStore } from "jotai"; +import { getCurrentAccount, storage } from "@kyoo/models/src/account-internal"; +import { useContext, useEffect } from "react"; + +type State = { + status: "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED"; + progress: number; + size: number; + availableSize: number; + error?: string; + pause: () => void; + resume: () => void; + stop: () => void; + play: () => void; +}; + +const downloadAtom = atom< + { + data: Episode | Movie; + info: WatchInfo; + path: string; + state: PrimitiveAtom; + }[] +>([]); + +const query = (query: QueryIdentifier, info: Account): Promise => + queryFn( + { + queryKey: toQueryKey(query), + signal: undefined as any, + meta: undefined, + apiUrl: info.apiUrl, + // use current user and current apiUrl to download this meta. + }, + query.parser, + info.token.access_token, + ); + +const listenToTask = ( + task: DownloadTask, + atom: PrimitiveAtom, + atomStore: ReturnType, +) => { + task + .begin(({ expectedBytes }) => atomStore.set(atom, (x) => ({ ...x, size: expectedBytes }))) + .progress((percent, availableSize, size) => + atomStore.set(atom, (x) => ({ ...x, percent, size, availableSize })), + ) + .done(() => { + atomStore.set(atom, (x) => ({ ...x, percent: 100, status: "DONE" })); + // apparently this is needed for ios /shrug i'm totaly gona forget this + // if i ever implement ios so keeping this here + completeHandler(task.id); + }) + .error((error) => atomStore.set(atom, (x) => ({ ...x, status: "FAILED", error }))); +}; + +export const useDownloader = () => { + const setDownloads = useSetAtom(downloadAtom); + const atomStore = useStore(); + + return async (type: "episode" | "movie", slug: string) => { + const account = getCurrentAccount()!; + const [data, info] = await Promise.all([ + query(Player.query(type, slug), account), + query(Player.infoQuery(type, slug), account), + ]); + + // TODO: support custom paths + const path = `${directories.documents}/${slug}-${data.id}.${info.extension}`; + const task = download({ + id: data.id, + // TODO: support variant qualities + url: `${account.apiUrl}/api/video/${type}/${slug}/direct`, + destination: path, + headers: { + Authorization: account.token.access_token, + }, + // TODO: Implement only wifi + // network: Network.ALL, + }); + + const state = atom({ + status: task.state, + progress: task.percent * 100, + size: task.totalBytes, + availableSize: task.bytesWritten, + pause: () => task.pause(), + resume: () => task.resume(), + stop: () => { + task.stop(); + setDownloads((x) => x.filter((y) => y.data.id !== task.id)); + }, + play: () => { + // TODO: set useQuery cache + // TODO: move to the play page. + }, + }); + + // we use the store instead of the onMount because we want to update the state to cache it even if it was not + // used during this launch of the app. + listenToTask(task, state, atomStore); + setDownloads((x) => [...x, { data, info, path, state }]); + }; +}; + +export const DownloadProvider = () => { + const store = useStore(); + + useEffect(() => { + async function run() { + const tasks = await checkForExistingDownloads(); + const downloads = store.get(downloadAtom); + for (const t of tasks) { + const downAtom = downloads.find((x) => x.data.id === t.id); + if (!downAtom) { + t.stop(); + continue; + } + listenToTask(t, downAtom.state, store); + } + ensureDownloadsAreRunning(); + } + run(); + }, [store]); +}; diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx index 60c9f29f..ad481a2a 100644 --- a/front/packages/ui/src/player/index.tsx +++ b/front/packages/ui/src/player/index.tsx @@ -24,7 +24,6 @@ import { Movie, MovieP, QueryIdentifier, - QueryPage, WatchInfo, WatchInfoP, useFetch, @@ -46,24 +45,6 @@ import { WatchStatusObserver } from "./watch-status-observer"; type Item = (Movie & { type: "movie" }) | (Episode & { type: "episode" }); -const query = (type: string, slug: string): QueryIdentifier => - type === "episode" - ? { - path: ["episode", slug], - params: { - fields: ["nextEpisode", "previousEpisode", "show"], - }, - parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })), - } - : { - path: ["movie", slug], - parser: MovieP.transform((x) => ({ ...x, type: "movie" })), - }; -const infoQuery = (type: string, slug: string): QueryIdentifier => ({ - path: ["video", type, slug, "info"], - parser: WatchInfoP, -}); - const mapData = ( data: Item | undefined, info: WatchInfo | undefined, @@ -86,14 +67,14 @@ const mapData = ( }; }; -export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ slug, type }) => { +export const Player = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => { const { css } = useYoshiki(); const { t } = useTranslation(); const router = useRouter(); const [playbackError, setPlaybackError] = useState(undefined); - const { data, error } = useFetch(query(type, slug)); - const { data: info, error: infoError } = useFetch(infoQuery(type, slug)); + const { data, error } = useFetch(Player.query(type, slug)); + const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug)); const previous = data && data.type === "episode" && data.previousEpisode ? `/watch/${data.previousEpisode.slug}` @@ -181,4 +162,27 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ ); }; -Player.getFetchUrls = ({ slug, type }) => [query(type, slug), infoQuery(type, slug)]; +Player.query = (type: "episode" | "movie", slug: string): QueryIdentifier => + type === "episode" + ? { + path: ["episode", slug], + params: { + fields: ["nextEpisode", "previousEpisode", "show"], + }, + parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })), + } + : { + path: ["movie", slug], + parser: MovieP.transform((x) => ({ ...x, type: "movie" })), + }; + +Player.infoQuery = (type: "episode" | "movie", slug: string): QueryIdentifier => ({ + path: ["video", type, slug, "info"], + parser: WatchInfoP, +}); + +// if more queries are needed, dont forget to update download.tsx to cache those. +Player.getFetchUrls = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => [ + Player.query(type, slug), + Player.infoQuery(type, slug), +]; diff --git a/front/yarn.lock b/front/yarn.lock index 8f230d2f..906d0378 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2857,6 +2857,15 @@ __metadata: languageName: node linkType: hard +"@kesha-antonov/react-native-background-downloader@npm:^2.10.0": + version: 2.10.0 + resolution: "@kesha-antonov/react-native-background-downloader@npm:2.10.0" + peerDependencies: + react-native: ">=0.57.0" + checksum: f56e4bf5f28b07d8b9e776ce3bdc6ed715ef3d9a95d3ec67b4852dc2992c51283550b5406106454557654a1c2503e920ec62d895aa6240aae97ba4f029df8a32 + languageName: node + linkType: hard + "@kyoo/models@workspace:^, @kyoo/models@workspace:packages/models": version: 0.0.0-use.local resolution: "@kyoo/models@workspace:packages/models" @@ -2869,6 +2878,9 @@ __metadata: "@tanstack/react-query": "*" react: "*" react-native: "*" + dependenciesMeta: + "@kesha-antonov/react-native-background-downloader": + optional: true peerDependenciesMeta: react-native-web: optional: true @@ -2935,6 +2947,7 @@ __metadata: version: 0.0.0-use.local resolution: "@kyoo/ui@workspace:packages/ui" dependencies: + "@kesha-antonov/react-native-background-downloader": ^2.10.0 "@kyoo/models": "workspace:^" "@kyoo/primitives": "workspace:^" "@shopify/flash-list": ^1.6.3 @@ -2942,6 +2955,7 @@ __metadata: react-native-uuid: ^2.0.1 typescript: ^5.3.2 peerDependencies: + "@kesha-antonov/react-native-background-downloader": "*" "@material-symbols/svg-400": "*" "@shopify/flash-list": ^1.3.1 "@tanstack/react-query": "*" @@ -2954,6 +2968,12 @@ __metadata: react-native-reanimated: "*" react-native-svg: "*" yoshiki: "*" + dependenciesMeta: + "@kesha-antonov/react-native-background-downloader": + optional: true + peerDependenciesMeta: + "@kesha-antonov/react-native-background-downloader": + optional: true languageName: unknown linkType: soft @@ -11269,6 +11289,7 @@ __metadata: "@formatjs/intl-displaynames": ^6.6.4 "@formatjs/intl-locale": ^3.4.3 "@gorhom/portal": ^1.0.14 + "@kesha-antonov/react-native-background-downloader": ^2.10.0 "@kyoo/ui": "workspace:^" "@material-symbols/svg-400": ^0.14.1 "@shopify/flash-list": 1.4.3