feat: ui improvments

This commit is contained in:
Viren070
2025-06-12 23:52:19 +01:00
parent 0f165752db
commit 39b1e84d87
13 changed files with 298 additions and 43 deletions
+2 -2
View File
@@ -216,8 +216,8 @@ LOG_SENSITIVE_INFO=true
# 'DEFAULT_' values are pre-filled. 'FORCE_' values override user settings.
# --- Stream Proxy Enabled ---
DEFAULT_PROXY_ENABLED=true # Default state for enabling a stream proxy.
FORCE_PROXY_ENABLED=false # Force stream proxy on/off for all users.
# DEFAULT_PROXY_ENABLED=true # Default state for enabling a stream proxy.
# FORCE_PROXY_ENABLED=false # Force stream proxy on/off for all users.
# --- Stream Proxy ID ---
# 'mediaflow' or 'stremthru'
+5
View File
@@ -273,6 +273,11 @@ export const UserDataSchema = z.object({
includedKeywords: z.array(z.string().min(1)).optional(),
excludedKeywords: z.array(z.string().min(1)).optional(),
preferredKeywords: z.array(z.string().min(1)).optional(),
randomiseResults: z.boolean().optional(),
enhanceResults: z.boolean().optional(),
enhancePosters: z.boolean().optional(),
excludedSeeders: z
.object({
min: z.number().optional(),
+7
View File
@@ -55,6 +55,7 @@ export interface ParseValue {
resolution: string | null;
languages: string[] | null;
languageEmojis: string[] | null;
wedontknowwhatakilometeris: string[] | null;
visualTags: string[] | null;
audioTags: string[] | null;
releaseGroup: string | null;
@@ -128,6 +129,12 @@ export abstract class BaseFormatter {
.map((lang) => languageToEmoji(lang) || lang)
.filter((value, index, self) => self.indexOf(value) === index)
: null,
wedontknowwhatakilometeris: stream.parsedFile?.languages
? stream.parsedFile.languages
.map((lang) => languageToEmoji(lang) || lang)
.map((emoji) => emoji.replace('🇬🇧', '🇺🇸🦅'))
.filter((value, index, self) => self.indexOf(value) === index)
: null,
visualTags: stream.parsedFile?.visualTags || null,
audioTags: stream.parsedFile?.audioTags || null,
releaseGroup: stream.parsedFile?.releaseGroup || null,
+1 -1
View File
@@ -47,7 +47,7 @@ export class GDriveFormatter extends BaseFormatter {
super(
{
name: `
{stream.proxied::istrue["🕵️ "||""]}{stream.type::=p2p["[P2P] "||""]}{service.shortName::exists["[{service.shortName}"||""]}{service.cached::istrue["⚡] "||""]}{service.cached::isfalse["⏳] "||""]}{addon.name}{stream.library::istrue[" (Your Media)"||""]} {stream.resolution::exists["{stream.resolution}"||""]}
{stream.proxied::istrue["🕵️ "||""]}{stream.type::=p2p["[P2P] "||""]}{service.shortName::exists["[{service.shortName}"||""]}{service.cached::istrue["⚡] "||""]}{service.cached::isfalse["⏳] "||""]}{addon.name}{stream.library::istrue[" (Your Media)"||""]} {stream.resolution::exists["{stream.resolution}"||""]}{stream.regexMatched::exists[" ({stream.regexMatched})"||""]}
`,
description: `
{stream.quality::exists["🎥 {stream.quality} "||""]}{stream.encode::exists["🎞️ {stream.encode} "||""]}{stream.releaseGroup::exists["🏷️ {stream.releaseGroup}"||""]}
+38 -2
View File
@@ -152,7 +152,9 @@ export class AIOStreams {
// step 7
// proxify streaming links if a proxy is provided
const proxifiedStreams = await this.proxifyStreams(limitedStreams);
const proxifiedStreams = this.applyModifications(
await this.proxifyStreams(limitedStreams)
);
// step 8
// if this.userData.precacheNextEpisode is true, start a new thread to request the next episode, check if
@@ -296,6 +298,20 @@ export class AIOStreams {
}
}
if (this.userData.enhancePosters) {
catalog = catalog.map((item) => {
if (Math.random() < 0.2) {
item.poster = Buffer.from(
constants.DEFAULT_POSTERS[
Math.floor(Math.random() * constants.DEFAULT_POSTERS.length)
],
'base64'
).toString('utf-8');
}
return item;
});
}
// step 4
return {
success: true,
@@ -2354,7 +2370,6 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
function keyValue(sortCriterion: SortCriterion, userData: UserData) {
const { key, direction } = sortCriterion;
const multiplier = direction === 'asc' ? 1 : -1;
// "quality" | "resolution" | "language" | "visualTag" | "audioTag" | "streamType" | "encode" | "size" | "service" | "seeders" | "addon" | "regexPatterns" | "cached" | "library"
switch (key) {
case 'cached':
return (
@@ -2481,6 +2496,27 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
);
}
private applyModifications(streams: ParsedStream[]): ParsedStream[] {
if (this.userData.randomiseResults) {
streams.sort(() => Math.random() - 0.5);
}
if (this.userData.enhanceResults) {
streams.forEach((stream) => {
if (Math.random() < 0.4) {
stream.filename = undefined;
stream.parsedFile = undefined;
stream.type = 'youtube';
stream.ytId = Buffer.from(constants.DEFAULT_YT_ID, 'base64').toString(
'utf-8'
);
stream.message =
'This stream has been artificially enhanced using the best AI on the market.';
}
});
}
return streams;
}
private limitStreams(streams: ParsedStream[]): ParsedStream[] {
if (!this.userData.resultLimits) {
return streams;
+6
View File
@@ -585,6 +585,12 @@ const SORT_CRITERIA = [
export const MIN_SIZE = 0;
export const MAX_SIZE = 100 * 1000 * 1000 * 1000; // 100GB
export const DEFAULT_POSTERS = [
'aHR0cHM6Ly93d3cucG5nbWFydC5jb20vZmlsZXMvMTEvUmlja3JvbGxpbmctUE5HLVBpYy5wbmc=',
];
export const DEFAULT_YT_ID = 'eHZGWmpvNVBnRzA=';
export const SORT_CRITERIA_DETAILS = {
quality: {
name: 'Quality',
+36 -31
View File
@@ -21,6 +21,7 @@ import {
BiLogOutCircle,
BiCog,
BiServer,
BiSmile,
} from 'react-icons/bi';
import { useRouter, usePathname } from 'next/navigation';
import { useDisclosure } from '@/hooks/disclosure';
@@ -32,6 +33,7 @@ import { Modal } from '@/components/ui/modal';
import { TextInput } from '@/components/ui/text-input';
import { toast } from 'sonner';
import { Tooltip } from '@/components/ui/tooltip';
import { useOptions } from '@/context/options';
type MenuItem = VerticalMenuItem & {
id: MenuId;
@@ -43,11 +45,14 @@ export function MainSidebar() {
const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar;
const { selectedMenu, setSelectedMenu } = useMenu();
const pathname = usePathname();
const { isOptionsEnabled, enableOptions } = useOptions();
const user = useUserData();
const signInModal = useDisclosure(false);
const [initialUuid, setInitialUuid] = React.useState<string | null>(null);
const clickHistory = React.useRef<number[]>([]);
React.useEffect(() => {
const uuidMatch = pathname.match(
/stremio\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/.*\/configure/
@@ -126,6 +131,16 @@ export function MainSidebar() {
isCurrent: selectedMenu === 'miscellaneous',
id: 'miscellaneous',
},
...(isOptionsEnabled
? [
{
name: 'Fun',
iconType: BiSmile,
isCurrent: selectedMenu === 'fun',
id: 'fun',
},
]
: []),
{
name: 'Save & Install',
iconType: BiSave,
@@ -134,32 +149,6 @@ export function MainSidebar() {
},
];
const bottomMenuItems: MenuItem[] = [
...(user.uuid && user.password
? [
{
name: 'Log Out',
iconType: BiLogOutCircle,
isCurrent: false,
id: 'unload-config' as MenuId,
onClick: () => {
confirmClearConfig.open();
},
},
]
: [
{
name: 'Log In',
iconType: BiLogInCircle,
isCurrent: signInModal.isOpen,
id: 'sign-in' as MenuId,
onClick: () => {
signInModal.open();
},
},
]),
];
const handleExpandSidebar = () => {
if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) {
setExpandSidebar(true);
@@ -206,11 +195,27 @@ export function MainSidebar() {
<div>
<div className="mb-4 p-4 pb-0 flex flex-col items-center w-full">
<img
src={user.userData.addonLogo || '/logo.png'}
alt="logo"
className="w-22.5 h-15"
/>
<div
className="flex items-center gap-2"
onClick={() => {
const now = Date.now();
const clicks = clickHistory.current.filter(
(time) => now - time < 5000
);
clicks.push(now);
clickHistory.current = clicks;
if (clicks.length >= 10) {
clickHistory.current = [];
enableOptions();
}
}}
>
<img
src={user.userData.addonLogo || '/logo.png'}
alt="logo"
className="w-22.5 h-15"
/>
</div>
<span className="text-xs text-gray-500">
{status
? status.tag.includes('nightly')
+4 -1
View File
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { UserDataProvider } from '@/context/userData';
import { LuffyError } from '@/components/shared/luffy-error';
import { TextGenerateEffect } from '@/components/shared/text-generate-effect';
import { OptionsProvider } from '@/context/options';
function ErrorOverlay({ error }: { error: string | null }) {
return (
@@ -76,7 +77,9 @@ export default function Home() {
<ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
<StatusProvider>
<UserDataProvider>
<AppContent />
<OptionsProvider>
<AppContent />
</OptionsProvider>
</UserDataProvider>
</StatusProvider>
</ThemeProvider>
@@ -10,6 +10,7 @@ import { MiscellaneousMenu } from './menu/miscellaneous';
import { SaveInstallMenu } from './menu/save-install';
import { FormatterMenu } from './menu/formatter';
import { ProxyMenu } from './menu/proxy';
import { OptionsMenu } from './menu/options';
export function MenuContent() {
const { selectedMenu } = useMenu();
@@ -33,6 +34,8 @@ export function MenuContent() {
return <MiscellaneousMenu />;
case 'save-install':
return <SaveInstallMenu />;
case 'fun':
return <OptionsMenu />;
default:
return (
<div className="p-8">
@@ -0,0 +1,63 @@
import React from 'react';
import { PageWrapper } from '../shared/page-wrapper';
import { SettingsCard } from '../shared/settings-card';
import { Switch } from '../ui/switch';
import { useUserData } from '@/context/userData';
export function OptionsMenu() {
return (
<PageWrapper className="space-y-4 p-4 sm:p-8">
<Content />
</PageWrapper>
);
}
function Content() {
const { userData, setUserData } = useUserData();
return (
<div className="flex flex-col w-full gap-4">
<div>
<h2>Options</h2>
<p className="text-[--muted]">{':)'}</p>
</div>
<SettingsCard title="Fun" className="w-full">
<div className="flex flex-col gap-4">
<Switch
label="Randomise results"
side="right"
value={userData.randomiseResults}
onValueChange={(value) =>
setUserData((prev) => ({
...prev,
randomiseResults: value,
}))
}
/>
<Switch
label="Enhance results"
side="right"
value={userData.enhanceResults}
onValueChange={(value) =>
setUserData((prev) => ({
...prev,
enhanceResults: value,
}))
}
/>
<Switch
label="Enhance posters"
side="right"
value={userData.enhancePosters}
onValueChange={(value) =>
setUserData((prev) => ({
...prev,
enhancePosters: value,
}))
}
/>
</div>
</SettingsCard>
</div>
);
}
@@ -8,10 +8,19 @@ import { PageWrapper } from '@/components/shared/page-wrapper';
import { Alert } from '@/components/ui/alert';
import { SettingsCard } from '../shared/settings-card';
import { toast } from 'sonner';
import { DownloadIcon, UploadIcon } from 'lucide-react';
import { CopyIcon, DownloadIcon, PlusIcon, UploadIcon } from 'lucide-react';
import { useStatus } from '@/context/status';
import { BiCopy } from 'react-icons/bi';
import { PageControls } from '../shared/page-controls';
import { useDisclosure } from '@/hooks/disclosure';
import { Modal } from '../ui/modal';
import { Switch } from '../ui/switch';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
export function SaveInstallMenu() {
return (
@@ -43,6 +52,9 @@ function Content() {
const baseUrl = status?.settings?.baseUrl || window.location.origin;
const [addonPassword, setAddonPassword] = React.useState('');
const importFileRef = React.useRef<HTMLInputElement>(null);
const installModal = useDisclosure(false);
const [filterCredentialsInExport, setFilterCredentialsInExport] =
React.useState(false);
React.useEffect(() => {
if (userData?.addonPassword) {
@@ -147,7 +159,20 @@ function Content() {
const handleExport = () => {
try {
const dataStr = JSON.stringify(userData, null, 2);
const dataStr = JSON.stringify(
{
...userData,
addonPassword: filterCredentialsInExport
? undefined
: userData.addonPassword,
services: userData?.services?.map((service) => ({
...service,
credentials: filterCredentialsInExport ? {} : service.credentials,
})),
},
null,
2
);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -321,7 +346,7 @@ function Content() {
</form>
</SettingsCard>
<SettingsCard
{/* <SettingsCard
title="Install"
description="Choose how you want to install your personalized addon. There is no need to reinstall the addon after updating your configuration above, unless you've updated your upstream addons."
>
@@ -346,6 +371,54 @@ function Content() {
</Button>
<Button onClick={copyManifestUrl}>Copy URL</Button>
</div>
</SettingsCard> */}
<SettingsCard
title="Install"
description="Install your addon using your preferred method. There usually isn't a need to reinstall the addon after updating your configuration above, unless you use catalogs and you've changed the order of them or the addons that provide them"
>
<Button intent="white" rounded onClick={installModal.open}>
Install
</Button>
<Modal
open={installModal.isOpen}
onOpenChange={installModal.toggle}
title="Install"
description="Install your addon"
>
<div className="flex flex-col gap-4">
<Button
onClick={() =>
window.open(
`stremio://${baseUrl.replace(/^https?:\/\//, '')}/stremio/${uuid}/${encryptedPassword}/manifest.json`
)
}
intent="primary"
className="w-full"
>
Stremio
</Button>
<Button
onClick={() =>
window.open(
`https://web.stremio.com/#/addons?addon=${encodedManifest}`
)
}
intent="primary"
className="w-full"
>
Stremio Web
</Button>
<Button
onClick={copyManifestUrl}
intent="primary"
className="w-full"
>
Copy URL
</Button>
</div>
</Modal>
</SettingsCard>
</>
)}
@@ -384,6 +457,27 @@ function Content() {
</label>
</div>
</div>
<div className="flex items-center gap-2 mt-4 w-full">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="export-settings" className="w-full">
<AccordionTrigger className="w-full">
Export Settings
</AccordionTrigger>
<AccordionContent className="w-full">
<div className="flex items-center justify-between w-full">
<Switch
value={filterCredentialsInExport ?? false}
onValueChange={(value) =>
setFilterCredentialsInExport(value)
}
side="right"
label="Exclude Credentials"
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</SettingsCard>
</div>
</>
@@ -212,7 +212,10 @@ function Content() {
</div>
</div>
<div className="space-y-4">
<SettingsCard>
<SettingsCard
title="Type"
description="Here, you can define different sort criteria for different types of content. Most people will only need to define the global sort criteria, and nothing else. However, if you wanted different sorting for cached and uncached content, you can do that here. You have to define the 'main' criteria to have cached at the top, and then separately define the cached and uncached criteria. If you also want to define different sorting for movies and series, you would do the same."
>
<Select
label="Sort Order Type"
options={[
@@ -266,8 +269,8 @@ function Content() {
{currentSortCriteria.length > 0 && (
<SettingsCard
title="Sort Order"
description="Drag to reorder your sort criteria. Click the direction icon to toggle between ascending and descending."
title="Order"
description="Drag to reorder your sort criteria for the currently selected type. Click the direction icon to toggle between ascending and descending."
>
<DndContext
modifiers={[restrictToVerticalAxis]}
+30
View File
@@ -0,0 +1,30 @@
import React, { createContext, useContext, useState } from 'react';
interface OptionsContextType {
isOptionsEnabled: boolean;
enableOptions: () => void;
}
const OptionsContext = createContext<OptionsContextType | undefined>(undefined);
export function OptionsProvider({ children }: { children: React.ReactNode }) {
const [isOptionsEnabled, setIsOptionsEnabled] = useState(false);
const enableOptions = () => {
setIsOptionsEnabled(true);
};
return (
<OptionsContext.Provider value={{ isOptionsEnabled, enableOptions }}>
{children}
</OptionsContext.Provider>
);
}
export function useOptions() {
const context = useContext(OptionsContext);
if (context === undefined) {
throw new Error('useOptions must be used within a OptionsProvider');
}
return context;
}