Browse Source

[cast] Tweaks to try and get it to work on older cast devices (#1657)

Manav Rathi 1 year ago
parent
commit
dbbb3c848a

+ 1 - 1
.github/workflows/auth-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (auth)"
 on:
     # Run on every push to a branch other than main that changes auth/
     push:
-        branches-ignore: [main, "deploy/**"]
+        branches-ignore: [main, "deploy/**", "deploy-f/**"]
         paths:
             - "auth/**"
             - ".github/workflows/auth-lint.yml"

+ 1 - 1
.github/workflows/desktop-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (desktop)"
 on:
     # Run on every push to a branch other than main that changes desktop/
     push:
-        branches-ignore: [main, "deploy/**"]
+        branches-ignore: [main, "deploy/**", "deploy-f/**"]
         paths:
             - "desktop/**"
             - ".github/workflows/desktop-lint.yml"

+ 1 - 1
.github/workflows/docs-verify-build.yml

@@ -6,7 +6,7 @@ name: "Verify build (docs)"
 on:
     # Run on every push to a branch other than main that changes docs/
     push:
-        branches-ignore: [main, "deploy/**"]
+        branches-ignore: [main, "deploy/**", "deploy-f/**"]
         paths:
             - "docs/**"
             - ".github/workflows/docs-verify-build.yml"

+ 1 - 1
.github/workflows/mobile-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (mobile)"
 on:
     # Run on every push to a branch other than main that changes mobile/
     push:
-        branches-ignore: [main, f-droid, "deploy/**"]
+        branches-ignore: [main, f-droid, "deploy/**", "deploy-f/**"]
         paths:
             - "mobile/**"
             - ".github/workflows/mobile-lint.yml"

+ 1 - 1
.github/workflows/server-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (server)"
 on:
     # Run on every push to a branch other than main that changes server/
     push:
-        branches-ignore: [main, "deploy/**"]
+        branches-ignore: [main, "deploy/**", "deploy-f/**"]
         paths:
             - "server/**"
             - ".github/workflows/server-lint.yml"

+ 1 - 1
.github/workflows/web-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (web)"
 on:
     # Run on every push to a branch other than main that changes web/
     push:
-        branches-ignore: [main, "deploy/**"]
+        branches-ignore: [main, "deploy/**", "deploy-f/**"]
         paths:
             - "web/**"
             - ".github/workflows/web-lint.yml"

+ 24 - 19
web/apps/cast/src/pages/index.tsx

@@ -12,25 +12,28 @@ export default function Index() {
     const [privateKeyB64, setPrivateKeyB64] = useState<string | undefined>();
     const [pairingCode, setPairingCode] = useState<string | undefined>();
 
-    const router = useRouter();
-
-    useEffect(() => {
-        init();
-    }, []);
+    // Keep a boolean flag to ensure that Cast Receiver starts only once even if
+    // pairing codes change.
+    const [haveInitializedCast, setHaveInitializedCast] = useState(false);
 
-    const init = () => {
-        register().then((r) => {
-            setPublicKeyB64(r.publicKeyB64);
-            setPrivateKeyB64(r.privateKeyB64);
-            setPairingCode(r.pairingCode);
-        });
-    };
+    const router = useRouter();
 
     useEffect(() => {
-        castReceiverLoadingIfNeeded().then((cast) =>
-            advertiseCode(cast, () => pairingCode),
-        );
-    }, []);
+        if (!pairingCode) {
+            register().then((r) => {
+                setPublicKeyB64(r.publicKeyB64);
+                setPrivateKeyB64(r.privateKeyB64);
+                setPairingCode(r.pairingCode);
+            });
+        } else {
+            if (!haveInitializedCast) {
+                castReceiverLoadingIfNeeded().then((cast) => {
+                    setHaveInitializedCast(true);
+                    advertiseCode(cast, () => pairingCode);
+                });
+            }
+        }
+    }, [pairingCode]);
 
     useEffect(() => {
         if (!publicKeyB64 || !privateKeyB64 || !pairingCode) return;
@@ -52,10 +55,12 @@ export default function Index() {
             storeCastData(data);
             await router.push("/slideshow");
         } catch (e) {
-            log.error("Failed to get cast data", e);
-            // Start again from the beginning.
+            // The pairing code becomes invalid after an hour, which will cause
+            // `getCastData` to fail. There might be other reasons this might
+            // fail too, but in all such cases, it is a reasonable idea to start
+            // again from the beginning.
+            log.warn("Failed to get cast data", e);
             setPairingCode(undefined);
-            init();
         }
     };
 

+ 2 - 0
web/apps/cast/src/pages/slideshow.tsx

@@ -46,6 +46,8 @@ export default function Slideshow() {
         };
     }, []);
 
+    console.log("Rendering slideshow", { loading, imageURL, nextImageURL });
+
     if (loading) return <PairedSuccessfullyOverlay />;
 
     return <SlideView url={imageURL} nextURL={nextImageURL} />;

+ 37 - 25
web/apps/cast/src/services/cast.ts

@@ -84,26 +84,17 @@ export const renderableImageURLs = async function* (castData: CastData) {
     const { collectionKey, castToken } = castData;
 
     /**
-     * We have a sliding window of four URLs, with the `urls[1]` being the one
-     * that is the one currently being shown in the slideshow.
-     *
-     * At each step, we shift the window towards the right by shifting out the
-     * leftmost (oldest) `urls[0]`, and adding a new one at the end.
-     *
-     * We can revoke url[0] when we shift it out because we know it is not being
-     * used anymore.
-     *
-     * We need to special case the first two renders to avoid revoking the
-     * initial URLs that are displayed the first two times. This results in a
-     * memory leak of the very first objectURL that we display.
+     * Keep a FIFO queue of the URLs that we've vended out recently so that we
+     * can revoke those that are not being shown anymore.
      */
-    const urls: string[] = [""];
-    let i = 0;
+    const previousURLs: string[] = [];
 
-    /**
-     * Number of milliseconds to keep the slide on the screen.
-     */
+    /** The URL pair that we will yield */
+    const urls: string[] = [];
+
+    /** Number of milliseconds to keep the slide on the screen. */
     const slideDuration = 10000; /* 10 s */
+
     /**
      * Time when we last yielded.
      *
@@ -137,16 +128,33 @@ export const renderableImageURLs = async function* (castData: CastData) {
                 continue;
             }
 
-            if (urls.length < 4) continue;
+            // Need at least a pair.
+            //
+            // There are two scenarios:
+            //
+            // - First run: urls will initially be empty, so gobble two.
+            //
+            // - Subsequently, urls will have the "next" / "preloaded" URL left
+            //   over from the last time. We'll promote that to being the one
+            //   that'll get displayed, and preload another one.
+            if (urls.length < 2) continue;
+
+            // The last element of previousURLs is the URL that is currently
+            // being shown on screen.
+            //
+            // The last to last element is the one that was shown prior to that,
+            // and now can be safely revoked.
+            if (previousURLs.length > 1)
+                URL.revokeObjectURL(previousURLs.shift());
+
+            // The URL that'll now get displayed on screen.
+            const url = ensure(urls.shift());
+            // The URL that we're preloading for next time around.
+            const nextURL = ensure(urls[0]);
 
-            const oldestURL = urls.shift();
-            if (oldestURL && i !== 1) URL.revokeObjectURL(oldestURL);
-            i += 1;
+            previousURLs.push(url);
 
-            const urlPair: RenderableImageURLPair = [
-                ensure(urls[0]),
-                ensure(urls[1]),
-            ];
+            const urlPair: RenderableImageURLPair = [url, nextURL];
 
             const elapsedTime = Date.now() - lastYieldTime;
             if (elapsedTime > 0 && elapsedTime < slideDuration)
@@ -299,6 +307,8 @@ const downloadFile = async (castToken: string, file: EnteFile) => {
         throw new Error("Can only cast images and live photos");
 
     const url = getCastFileURL(file.id);
+    // TODO(MR): Remove if usused eventually
+    // const url = getCastThumbnailURL(file.id);
     const resp = await HTTPService.get(
         url,
         null,
@@ -313,6 +323,8 @@ const downloadFile = async (castToken: string, file: EnteFile) => {
     const decrypted = await cryptoWorker.decryptFile(
         new Uint8Array(resp.data),
         await cryptoWorker.fromB64(file.file.decryptionHeader),
+        // TODO(MR): Remove if usused eventually
+        // await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
         file.key,
     );
     return new Response(decrypted).blob();

+ 56 - 10
web/apps/cast/src/services/pair.ts

@@ -116,24 +116,68 @@ export const advertiseCode = (
     const namespace = "urn:x-cast:pair-request";
 
     const options = new cast.framework.CastReceiverOptions();
-    // Do not automatically close the connection when the sender disconnects.
-    options.maxInactivity = 3600; /* 1 hour */
+    // We don't use the media features of the Cast SDK.
+    options.skipPlayersLoad = true;
     // TODO:Is this required? The docs say "(The default type of a message bus
     // is JSON; if not provided here)."
     options.customNamespaces = Object.assign({});
     options.customNamespaces[namespace] =
         cast.framework.system.MessageType.JSON;
-    // TODO: This looks like the only one needed, but a comment with the reason
-    // might be good.
+    // Do not stop the casting if the receiver is unreachable. A user should be
+    // able to start a cast on their phone and then put it away, leaving the
+    // cast running on their big screen.
     options.disableIdleTimeout = true;
 
+    // The collection ID with which we paired. If we get another connection
+    // request for a different collection ID, restart the app to allow them to
+    // reconnect using a freshly generated pairing code.
+    //
+    // If the request does not have a collectionID, forego this check.
+    let pairedCollectionID: string | undefined;
+
+    type ListenerProps = {
+        senderId: string;
+        data: unknown;
+    };
+
     // Reply with the code that we have if anyone asks over Chromecast.
-    const incomingMessageListener = ({ senderId }: { senderId: string }) => {
+    const incomingMessageListener = ({ senderId, data }: ListenerProps) => {
+        const restart = (reason: string) => {
+            log.error(`Restarting app because ${reason}`);
+            // context.stop will close the tab but it'll get reopened again
+            // immediately since the client app will reconnect in the scenarios
+            // where we're calling this function.
+            context.stop();
+        };
+
+        const collectionID =
+            data &&
+            typeof data == "object" &&
+            typeof data["collectionID"] == "string"
+                ? data["collectionID"]
+                : undefined;
+
+        if (pairedCollectionID && pairedCollectionID != collectionID) {
+            restart(`incoming request for a new collection ${collectionID}`);
+            return;
+        }
+
+        pairedCollectionID = collectionID;
+
         const code = pairingCode();
         if (!code) {
-            log.warn(
-                "Ignoring incoming Chromecast message because we do not yet have a pairing code",
-            );
+            // Our caller waits until it has a pairing code before it calls
+            // `advertiseCode`, but there is still an edge case where we can
+            // find ourselves without a pairing code:
+            //
+            // 1. The current pairing code expires. We start the process to get
+            //    a new one.
+            //
+            // 2. But before that happens, someone connects.
+            //
+            // The window where this can happen is short, so if we do find
+            // ourselves in this scenario,
+            restart("we got a pairing request when refreshing pairing codes");
             return;
         }
 
@@ -147,8 +191,10 @@ export const advertiseCode = (
         incomingMessageListener as unknown as SystemEventHandler,
     );
 
-    // Shutdown ourselves if the sender disconnects.
-    // TODO(MR): I assume the page reloads on shutdown. Is that correct?
+    // Close the (chromecast) tab if the sender disconnects.
+    //
+    // Chromecast does a "shutdown" of our cast app when we call `context.stop`.
+    // This translates into it closing the tab where it is showing our app.
     context.addEventListener(
         cast.framework.system.EventType.SENDER_DISCONNECTED,
         () => context.stop(),