From 482f2e77f7346ebf72dcd2c04b1cf4bb3ace19e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linkr=C3=A9dible?= Date: Sun, 11 Jan 2026 00:25:18 +0100 Subject: [PATCH] feat: add multi-account switching support Implements functionality to switch between multiple Stremio accounts stored locally without logging out. Key changes: - Added AccountManager to handle localStorage persistence, token management, and settings. - Added ProfilePicker component with UI for selecting, managing, and deleting profiles. - Integrated "Change Profile" and "Log out" actions in the NavMenu. - Improved accessibility (aria-labels, semantic buttons) and UI consistency (CSS variables). - Fixed race conditions in authentication flow and improved hook dependencies. - Added graceful handling of token expiration. --- src/App/withProtectedRoutes.js | 4 +- src/common/AccountManager.js | 86 +++++++ .../NavMenu/NavMenuContent.js | 44 +++- .../HorizontalNavBar/NavMenu/styles.less | 37 ++- src/components/ProfilePicker/ProfilePicker.js | 152 ++++++++++++ src/components/ProfilePicker/styles.less | 232 ++++++++++++++++++ src/components/index.ts | 2 + src/routes/Intro/Intro.js | 95 ++++++- 8 files changed, 628 insertions(+), 24 deletions(-) create mode 100644 src/common/AccountManager.js create mode 100644 src/components/ProfilePicker/ProfilePicker.js create mode 100644 src/components/ProfilePicker/styles.less diff --git a/src/App/withProtectedRoutes.js b/src/App/withProtectedRoutes.js index a16e0c0c7..af32f3e8e 100644 --- a/src/App/withProtectedRoutes.js +++ b/src/App/withProtectedRoutes.js @@ -15,7 +15,9 @@ const withProtectedRoutes = (Component) => { previousAuthRef.current = profile.auth; }, [profile]); const onRouteChange = React.useCallback((routeConfig) => { - if (profile.auth !== null && routeConfig.component === Intro) { + // Allow access to /intro when ?profiles=true (for profile switching) + const urlParams = new URLSearchParams(window.location.hash.split('?')[1]); + if (profile.auth !== null && routeConfig.component === Intro && urlParams.get('profiles') !== 'true') { window.location.replace('#/'); return true; } diff --git a/src/common/AccountManager.js b/src/common/AccountManager.js new file mode 100644 index 000000000..fc32eb42d --- /dev/null +++ b/src/common/AccountManager.js @@ -0,0 +1,86 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +const STORAGE_KEY = 'stremio-multi-accounts'; + +const AccountManager = { + getAccounts: () => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; + } catch (_e) { + return []; + } + }, + + addAccount: (profile, authKey, settings = null) => { + try { + const accounts = AccountManager.getAccounts(); + const existing = accounts.find((a) => a.email === profile.email); + const filtered = accounts.filter((a) => a.email !== profile.email); + + const newAccount = { + email: profile.email, + avatar: profile.avatar, + authKey: authKey, + settings: settings || existing?.settings || null, + lastActive: Date.now() + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount])); + } catch (_e) { + console.error('Failed to add account', _e); + } + }, + + updateSettings: (email, settings) => { + try { + const accounts = AccountManager.getAccounts(); + const updated = accounts.map((a) => + a.email === email ? { ...a, settings } : a + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (_e) { + console.error('Failed to update settings', _e); + } + }, + + getSettings: (email) => { + const accounts = AccountManager.getAccounts(); + const account = accounts.find((a) => a.email === email); + return account?.settings || null; + }, + + removeAccount: (email) => { + try { + const accounts = AccountManager.getAccounts(); + const filtered = accounts.filter((a) => a.email !== email); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + } catch (_e) { + console.error('Failed to remove account', _e); + } + }, + + switchTo: (email) => { + const accounts = AccountManager.getAccounts(); + const target = accounts.find((a) => a.email === email); + if (target && target.authKey) { + let updatedTarget = target; + const updatedAccounts = accounts.map((a) => { + if (a.email === email) { + const newAccount = { ...a, lastActive: Date.now() }; + updatedTarget = newAccount; + return newAccount; + } + return a; + }); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedAccounts)); + } catch (_e) { + console.error('Failed to switch account', _e); + } + return updatedTarget; + } + return null; + } +}; + +module.exports = AccountManager; \ No newline at end of file diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index de0a02212..9505ac16c 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -8,6 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { useServices } = require('stremio/services'); const { Button } = require('stremio/components'); const { default: useFullscreen } = require('stremio/common/useFullscreen'); +const AccountManager = require('stremio/common/AccountManager'); const useProfile = require('stremio/common/useProfile'); const usePWA = require('stremio/common/usePWA'); const useTorrent = require('stremio/common/useTorrent'); @@ -29,14 +30,33 @@ const NavMenuContent = ({ onClick }) => { profile.settings.streamingServerWarningDismissed.getTime() > Date.now() ); }, [profile.settings, streamingServer.settings]); + const onChangeProfile = React.useCallback((event) => { + event.stopPropagation(); + if (profile.auth && profile.auth.user && profile.auth.key) { + AccountManager.addAccount(profile.auth.user, profile.auth.key, profile.settings); + } + window.location.hash = '#/intro?profiles=true'; + }, [profile.auth, profile.settings]); const logoutButtonOnClick = React.useCallback(() => { + const currentEmail = profile.auth?.user?.email; + const accounts = AccountManager.getAccounts(); + const remainingAccounts = accounts.filter((acc) => acc.email !== currentEmail); + + if (currentEmail) { + AccountManager.removeAccount(currentEmail); + } + core.transport.dispatch({ action: 'Ctx', args: { action: 'Logout' } }); - }, []); + + if (remainingAccounts.length > 0) { + window.location.hash = '#/intro?profiles=true'; + } + }, [profile.auth, core]); const onPlayMagnetLinkClick = React.useCallback(async () => { try { const clipboardText = await navigator.clipboard.readText(); @@ -64,9 +84,25 @@ const NavMenuContent = ({ onClick }) => {
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
- +
+ { + profile.auth !== null ? + + : + null + } + { + profile.auth !== null ? +
/
+ : + null + } + +
{ diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less b/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less index 31b997d38..01bcf5eff 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less @@ -61,22 +61,35 @@ } } - .logout-button-container { + .user-actions-container { flex: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; - &:hover, &:focus { - outline: none; - - .logout-label { - text-decoration: underline; - } + .user-actions-separator { + color: var(--primary-foreground-color); + opacity: 0.5; + font-size: 0.9rem; } - .logout-label { - flex: 1; - font-size: 0.9rem; - font-weight: 500; - color: var(--primary-foreground-color); + .user-action-button { + flex: none; + + &:hover, &:focus { + outline: none; + + .user-action-label { + text-decoration: underline; + } + } + + .user-action-label { + font-size: 0.9rem; + font-weight: 500; + color: var(--primary-foreground-color); + } } } } diff --git a/src/components/ProfilePicker/ProfilePicker.js b/src/components/ProfilePicker/ProfilePicker.js new file mode 100644 index 000000000..0dd359733 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePicker.js @@ -0,0 +1,152 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const React = require('react'); +const { useTranslation } = require('react-i18next'); +const PropTypes = require('prop-types'); +const classnames = require('classnames'); +const { default: Icon } = require('@stremio/stremio-icons/react'); +const { Image } = require('stremio/components'); +const styles = require('./styles.less'); + +const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => { + const { t } = useTranslation(); + const displayName = profile.email + ? profile.email.split('@')[0] + : t('USER', { defaultValue: 'User' }); + + const handleClick = React.useCallback((event) => { + event.stopPropagation(); + if (!isEditing) { + onSelect(profile); + } + }, [isEditing, onSelect, profile]); + + const handleDelete = React.useCallback((event) => { + event.stopPropagation(); + onDelete(profile); + }, [onDelete, profile]); + + return ( + + )} + + {displayName} + + ); +}; + +ProfileItem.propTypes = { + profile: PropTypes.shape({ + email: PropTypes.string, + avatar: PropTypes.string, + authKey: PropTypes.string, + settings: PropTypes.object, + lastActive: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) + }).isRequired, + isEditing: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +const AddProfileButton = ({ onClick, t }) => { + return ( + + ); +}; + +AddProfileButton.propTypes = { + onClick: PropTypes.func.isRequired, + t: PropTypes.func.isRequired +}; + +const ProfilePicker = ({ profiles = [], onSelectProfile, onAddProfile, onDeleteProfile }) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = React.useState(false); + + const toggleEditMode = React.useCallback(() => { + setIsEditing((prev) => !prev); + }, []); + + const handleDelete = React.useCallback((profile) => { + if (onDeleteProfile) { + onDeleteProfile(profile); + } + }, [onDeleteProfile]); + + const title = isEditing + ? t('MANAGE_PROFILES', { defaultValue: 'Manage Profiles' }) + : t('WHO_IS_WATCHING', { defaultValue: 'Who is watching?' }); + + const buttonText = isEditing + ? t('DONE', { defaultValue: 'DONE' }) + : t('MANAGE_PROFILES', { defaultValue: 'MANAGE PROFILES' }); + + return ( +
+
+
+

{title}

+ +
+ {profiles.map((profile, index) => ( + + ))} + {!isEditing && } +
+ + +
+
+ ); +}; + +ProfilePicker.propTypes = { + profiles: PropTypes.array, + onSelectProfile: PropTypes.func, + onAddProfile: PropTypes.func, + onDeleteProfile: PropTypes.func +}; + +module.exports = ProfilePicker; diff --git a/src/components/ProfilePicker/styles.less b/src/components/ProfilePicker/styles.less new file mode 100644 index 000000000..105ac5009 --- /dev/null +++ b/src/components/ProfilePicker/styles.less @@ -0,0 +1,232 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.profile-picker-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + font-family: 'PlusJakartaSans', sans-serif; + user-select: none; + overflow-y: auto; +} + +.background-container { + z-index: -1; + position: fixed; + top: -1rem; + bottom: -1rem; + left: -1rem; + right: -1rem; + background: url('/images/background_1.svg'), url('/images/background_2.svg'); + background-color: var(--primary-background-color); + background-position: bottom left, top right; + background-size: 53%, 54%; + background-repeat: no-repeat; + filter: blur(6rem); +} + +.profile-picker-container { + text-align: center; + animation: fadeIn 0.4s ease-in-out; + overflow: visible; +} + +.title { + color: var(--primary-foreground-color); + font-size: 3rem; + font-weight: 600; + margin-bottom: 2rem; + user-select: none; + transition: all 0.3s ease; +} + +.profiles-grid { + display: flex; + gap: 2rem; + justify-content: center; + flex-wrap: wrap; + min-height: 200px; + overflow: visible; +} + +.profile-item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + pointer-events: auto; + transition: transform 0.2s ease; + overflow: visible; + padding: 10px; + background: transparent; + border: none; + font-family: inherit; + + &:hover { + .avatar-wrapper { + border-color: var(--primary-foreground-color); + transform: scale(1.05); + } + .profile-name { + color: var(--primary-foreground-color); + } + } + + &:active { + .avatar-wrapper { + transform: scale(0.98); + } + } + + &.editing { + cursor: default; + + &:hover { + .avatar-wrapper { + transform: none; + border-color: transparent; + } + } + + .avatar-wrapper { + opacity: 0.7; + } + } +} + +.avatar-wrapper { + width: 10vw; + height: 10vw; + min-width: 84px; + min-height: 84px; + max-width: 200px; + max-height: 200px; + border-radius: 50%; + overflow: hidden; + margin-bottom: 1rem; + border: 3px solid transparent; + transition: border-color 0.2s, transform 0.2s, opacity 0.2s; + position: relative; + + &.add-profile-wrapper { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--overlay-color); + border: 3px solid var(--secondary-foreground-color); + + &:hover { + background-color: var(--primary-foreground-color); + border-color: var(--primary-foreground-color); + .add-icon { + fill: var(--primary-background-color); + } + } + } +} + +.avatar { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + border-radius: 50%; +} + +.delete-button { + position: absolute; + top: -8px; + right: -8px; + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--signal-error-color); + border: 2px solid var(--primary-background-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background-color 0.2s ease; + z-index: 10; + padding: 0; + + &:hover { + transform: scale(1.15); + } + + &:active { + transform: scale(0.95); + } +} + +.delete-icon { + width: 16px; + height: 16px; + fill: var(--primary-foreground-color); + pointer-events: none; +} + +.add-icon { + width: 50%; + height: 50%; + fill: var(--primary-foreground-color); + transition: fill 0.2s; + pointer-events: none; +} + +.profile-name { + color: var(--primary-foreground-color); + font-size: 1.2rem; + font-weight: 500; + transition: color 0.2s; + max-width: 12vw; + min-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + pointer-events: none; +} + +.manage-button { + margin-top: 3rem; + background: transparent; + border: 1px solid var(--primary-foreground-color); + color: var(--primary-foreground-color); + padding: 0.5rem 1.5rem; + font-size: 1.1rem; + letter-spacing: 2px; + cursor: pointer; + display: inline-block; + user-select: none; + transition: all 0.2s ease; + border-radius: 3.5rem; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.done-button { + background-color: var(--primary-accent-color); + color: var(--primary-foreground-color); + border-color: var(--primary-accent-color); + opacity: 1; + + &:hover { + opacity: 0.9; + } + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(1.1); } + to { opacity: 1; transform: scale(1); } +} \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index a47c2c709..12d195a38 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -30,8 +30,10 @@ import TextInput from './TextInput'; import Toggle from './Toggle'; import Transition from './Transition'; import Video from './Video'; +import ProfilePicker from './ProfilePicker/ProfilePicker'; export { + ProfilePicker, AddonDetailsModal, BottomSheet, Button, diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index f04302fc7..db4524bc5 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -8,11 +8,12 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { Modal, useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); const { useBinaryState } = require('stremio/common'); -const { Button, Image, Checkbox } = require('stremio/components'); +const { Button, Image, Checkbox, ProfilePicker } = require('stremio/components'); const CredentialsTextInput = require('./CredentialsTextInput'); const PasswordResetModal = require('./PasswordResetModal'); const useFacebookLogin = require('./useFacebookLogin'); const { default: useAppleLogin } = require('./useAppleLogin'); +const AccountManager = require('stremio/common/AccountManager'); const styles = require('./styles'); @@ -249,8 +250,10 @@ const Intro = ({ queryParams }) => { dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' }); }, []); const switchFormOnClick = React.useCallback(() => { - const queryParams = new URLSearchParams([['form', state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM]]); - window.location = `#/intro?${queryParams.toString()}`; + const newForm = state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM; + const currentParams = new URLSearchParams(window.location.hash.split('?')[1]); + currentParams.set('form', newForm); + window.location = `#/intro?${currentParams.toString()}`; }, [state.form]); React.useEffect(() => { if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) { @@ -262,15 +265,32 @@ const Intro = ({ queryParams }) => { errorRef.current.scrollIntoView(); } }, [state.error]); + const [savedAccounts, setSavedAccounts] = React.useState(() => AccountManager.getAccounts()); + const [pendingProfile, setPendingProfile] = React.useState(null); + const showProfilesParam = queryParams.get('profiles') === 'true'; + const [showProfiles, setShowProfiles] = React.useState(showProfilesParam || savedAccounts.length > 0); React.useEffect(() => { - if (routeFocused) { + if (routeFocused && !showProfiles && emailRef.current) { emailRef.current.focus(); } - }, [state.form, routeFocused]); + }, [state.form, routeFocused, showProfiles]); React.useEffect(() => { const onCoreEvent = ({ event, args }) => { switch (event) { case 'UserAuthenticated': { + if (args.auth && args.auth.key) { + AccountManager.addAccount(args.user, args.auth.key); + } + if (pendingProfile && pendingProfile.settings) { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: pendingProfile.settings + } + }); + } + setPendingProfile(null); closeLoaderModal(); if (routeFocused) { window.location = '#/'; @@ -280,8 +300,23 @@ const Intro = ({ queryParams }) => { case 'Error': { if (args.source.event === 'UserAuthenticated') { closeLoaderModal(); + if (pendingProfile) { + AccountManager.removeAccount(pendingProfile.email); + setSavedAccounts(AccountManager.getAccounts()); + dispatch({ type: 'set-form', form: LOGIN_FORM }); + dispatch({ + type: 'error', + error: t('SESSION_EXPIRED', { defaultValue: 'Session expired. Please log in again.' }) + }); + dispatch({ + type: 'change-credentials', + name: 'email', + value: pendingProfile.email + }); + setShowProfiles(false); + setPendingProfile(null); + } } - break; } } @@ -290,7 +325,53 @@ const Intro = ({ queryParams }) => { return () => { core.transport.off('CoreEvent', onCoreEvent); }; - }, [routeFocused]); + }, [routeFocused, pendingProfile, t, closeLoaderModal, dispatch, core]); + const onSelectProfile = React.useCallback((profile) => { + if (pendingProfile) return; + const account = AccountManager.switchTo(profile.email); + if (account && account.authKey) { + setPendingProfile({ ...profile, settings: account.settings }); + openLoaderModal(); + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'Authenticate', + args: { + type: 'LoginWithToken', + token: account.authKey + } + } + }); + } else { + dispatch({ + type: 'change-credentials', + name: 'email', + value: profile.email + }); + setShowProfiles(false); + } + }, [core, openLoaderModal, pendingProfile, dispatch]); + const onAddProfile = React.useCallback(() => { + setShowProfiles(false); + }, []); + const onDeleteProfile = React.useCallback((profile) => { + AccountManager.removeAccount(profile.email); + const updatedAccounts = AccountManager.getAccounts(); + setSavedAccounts(updatedAccounts); + if (updatedAccounts.length === 0) { + setShowProfiles(false); + } + }, []); + if (showProfiles && savedAccounts.length > 0) { + return ( + + ); + } return (