mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: ui improvments
This commit is contained in:
+2
-2
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"||""]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user