refactor: re-design simple mode toggle.

This commit is contained in:
arabcoders 2026-06-19 17:41:04 +03:00
parent 72f2373c42
commit 3381ca3805
15 changed files with 2459 additions and 2344 deletions

25
FAQ.md
View file

@ -3,6 +3,9 @@
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
or the `environment:` section in `compose.yaml` file. or the `environment:` section in `compose.yaml` file.
<details>
<summary>Click to expand</summary>
| Environment Variable | Description | Default | | Environment Variable | Description | Default |
| ------------------------------- | ------------------------------------------------------------------- | --------------------- | | ------------------------------- | ------------------------------------------------------------------- | --------------------- |
| TZ | The timezone to use for the application | `(not_set)` | | TZ | The timezone to use for the application | `(not_set)` |
@ -61,19 +64,19 @@ or the `environment:` section in `compose.yaml` file.
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` | | YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` | | YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` | | YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
</details>
> [!NOTE] > [!NOTE]
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`. > To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download. > The extractor name must be uppercase. You can find the extractor name in the download logs. This value cannot be
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored. > higher than `YTP_MAX_WORKERS`; higher values are ignored.
>
> [!IMPORTANT] > `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page. > WebUI Settings. `/?simple=1` forces and saves Simple for that browser.
>
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS > `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day. > history from the database.
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
# Browser extensions & bookmarklets # Browser extensions & bookmarklets

View file

@ -131,10 +131,9 @@ class LogWrapper:
if level < target.level: if level < target.level:
continue continue
if target.logger:
log_kwargs = {**kwargs}
log_kwargs.setdefault("stacklevel", 3)
if isinstance(target.target, logging.Logger): if isinstance(target.target, logging.Logger):
log_kwargs: dict[str, Any] = {**kwargs}
log_kwargs.setdefault("stacklevel", 3)
target.target.log(level, msg, *args, **log_kwargs) target.target.log(level, msg, *args, **log_kwargs)
elif callable(target.target): elif callable(target.target):
target.target(level, msg, *args, **kwargs) target.target(level, msg, *args, **kwargs)

View file

@ -144,7 +144,7 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
else: else:
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code) return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
return web.FileResponse( response = web.FileResponse(
path=file_path, path=file_path,
headers={ headers={
"Pragma": "public", "Pragma": "public",
@ -154,6 +154,16 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
if STATIC_STATE.index_file is not None and file_path == STATIC_STATE.index_file:
response.set_cookie(
name="simple_mode",
value="true" if config.simple_mode else "false",
path=config.base_path if "/" != config.base_path else "/",
samesite="Lax",
)
return response
def setup_static_routes(root_path: Path, config: Config) -> None: def setup_static_routes(root_path: Path, config: Config) -> None:
""" """

View file

@ -0,0 +1,159 @@
<template>
<UApp :toaster="toaster">
<slot :openSettings="open" :reloadBg="loadBg" :bgLoading="bgLoading" />
<SettingsPanel
:isOpen="settings"
:isLoading="bgLoading"
direction="right"
@close="settings = false"
@reload_bg="loadBg(true)"
/>
</UApp>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core';
import SettingsPanel from '~/components/SettingsPanel.vue';
import type { toastPosition } from '~/composables/useNotification';
import type { YTDLPOption } from '~/types/ytdlp';
import { request, syncOpacity } from '~/utils';
const props = withDefaults(
defineProps<{
mode?: 'regular' | 'simple';
loadOpts?: boolean;
}>(),
{
mode: 'regular',
loadOpts: false,
},
);
const emit = defineEmits<{ ready: [] }>();
const cfg = useYtpConfig();
const sock = useAppSocket();
const bgOn = useStorage<boolean>('random_bg', true);
const bgOpacity = useStorage<number>('random_bg_opacity', 0.95);
const anim = useStorage<boolean>('page_anims', true);
const toastPos = useStorage<toastPosition>('toast_position', 'top-right');
const settings = ref(false);
const bg = ref('');
const bgLoading = ref(false);
const toaster = computed(() => ({
position: toastPos.value,
max: 5,
expand: true,
progress: true,
}));
const open = (): void => {
settings.value = true;
};
const setMode = (): void => {
document.documentElement.classList.toggle('simple-mode', props.mode === 'simple');
};
const clearBg = (): void => {
const html = document.documentElement;
const body = document.querySelector('body');
html.classList.remove('bg-fanart');
html.removeAttribute('style');
body?.removeAttribute('style');
bg.value = '';
};
const loadBg = async (force = false): Promise<void> => {
if (bgLoading.value) {
return;
}
try {
bgLoading.value = true;
const resp = await request(`/api/random/background${force ? '?force=true' : ''}`);
if (resp.status === 200) {
bg.value = URL.createObjectURL(await resp.blob());
}
} catch (e) {
console.error(e);
} finally {
bgLoading.value = false;
}
};
defineExpose({ open, loadBg });
const syncBg = async (): Promise<void> => {
if (!bgOn.value) {
clearBg();
return;
}
if (!bg.value) {
await loadBg();
}
};
const loadOptions = async (): Promise<void> => {
try {
const resp = await request('/api/yt-dlp/options');
if (resp.ok) {
cfg.ytdlp_options = (await resp.json()) as Array<YTDLPOption>;
}
} catch {}
};
onMounted(async () => {
setMode();
try {
await cfg.loadConfig();
} catch {}
if (props.loadOpts) {
await loadOptions();
}
try {
sock.connect();
} catch {}
await syncBg();
emit('ready');
});
onBeforeUnmount(() => {
if (props.mode === 'simple') {
document.documentElement.classList.remove('simple-mode');
}
});
watch(bgOn, () => void syncBg());
watch(bgOpacity, () => bgOn.value && syncOpacity());
watch(anim, (on) => document.documentElement.classList.toggle('no-page-anim', !on), {
immediate: true,
});
watch(bg, () => {
if (!bgOn.value || !bg.value) {
return;
}
document.documentElement.setAttribute(
'style',
[
'background-color: unset',
'display: block',
'min-height: 100%',
'min-width: 100%',
`background-image: url(${bg.value})`,
].join('; '),
);
document.documentElement.classList.add('bg-fanart');
syncOpacity();
});
</script>

View file

@ -0,0 +1,43 @@
<template>
<UAlert
v-if="sock.connectionStatus !== 'connected'"
color="warning"
variant="soft"
orientation="horizontal"
:title="title"
>
<template #leading>
<UIcon
:name="icon"
:class="[
'size-4 shrink-0 text-warning',
sock.connectionStatus === 'connecting' ? 'animate-spin' : '',
]"
/>
</template>
<template v-if="sock.connectionStatus === 'disconnected'" #actions>
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="sock.reconnect()">
Reconnect
</UButton>
</template>
</UAlert>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const sock = useAppSocket();
const title = computed(() => {
if (sock.connectionStatus === 'connecting') {
return 'Connecting to websocket server...';
}
return 'Websocket connection lost.';
});
const icon = computed(() =>
sock.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
);
</script>

View file

@ -264,6 +264,10 @@
.markdown-alert-caution { .markdown-alert-caution {
--markdown-alert-accent: var(--ui-error); --markdown-alert-accent: var(--ui-error);
} }
.docs-markdown details summary {
cursor: pointer;
user-select: none;
}
</style> </style>
<template> <template>

View file

@ -20,7 +20,9 @@
size="sm" size="sm"
square square
icon="i-lucide-x" icon="i-lucide-x"
class="ml-auto shrink-0 sm:hidden" aria-label="Close settings"
title="Close settings"
class="ml-auto shrink-0"
@click="emitter('close')" @click="emitter('close')"
/> />
</div> </div>
@ -37,14 +39,31 @@
</template> </template>
<template #body> <template #body>
<USwitch <UFormField label="" class="w-full" :ui="settingsFieldUi">
v-model="simpleMode" <USelect
class="w-full" v-model="draftMode"
size="lg" :items="modeItems"
:ui="settingsSwitchUi" value-key="value"
:label="simpleMode ? 'Simple View' : 'Regular View'" label-key="label"
description="The simple view is ideal for non-technical users and mobile devices." size="lg"
/> class="w-full"
:ui="{ base: 'w-full' }"
/>
<div class="mt-3 flex justify-end">
<UButton
color="primary"
variant="soft"
size="sm"
icon="i-lucide-save"
:disabled="!modeChanged || savingMode"
:loading="savingMode"
@click="saveMode"
>
Save layout
</UButton>
</div>
</UFormField>
<USwitch <USwitch
v-model="page_anims" v-model="page_anims"
@ -91,12 +110,12 @@
class="w-full" class="w-full"
v-if="bg_enable" v-if="bg_enable"
label="Background visibility" label="Background visibility"
:hint="String(parseFloat(String(1.0 - bg_opacity)).toFixed(2))" :hint="`${Math.round(bgVisibilityModel * 100)}%`"
> >
<USlider <USlider
v-model="bgOpacityModel" v-model="bgVisibilityModel"
:min="0.5" :min="0"
:max="1" :max="0.5"
:step="0.01" :step="0.01"
size="lg" size="lg"
class="w-full" class="w-full"
@ -114,12 +133,7 @@
</template> </template>
<template #body> <template #body>
<UFormField <UFormField v-if="!modeOn" label="URL Separator" class="w-full" :ui="settingsFieldUi">
v-if="!simpleMode"
label="URL Separator"
class="w-full"
:ui="settingsFieldUi"
>
<USelect <USelect
v-model="separator" v-model="separator"
:items="separatorItems" :items="separatorItems"
@ -286,6 +300,7 @@ import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useNotification } from '~/composables/useNotification'; import { useNotification } from '~/composables/useNotification';
import type { notificationTarget, toastPosition } from '~/composables/useNotification'; import type { notificationTarget, toastPosition } from '~/composables/useNotification';
import { useMode, type Mode } from '~/composables/useMode';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -302,7 +317,6 @@ const props = withDefaults(
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>(); const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
const config = useYtpConfig();
const notification = useNotification(); const notification = useNotification();
const bg_enable = useStorage<boolean>('random_bg', true); const bg_enable = useStorage<boolean>('random_bg', true);
@ -315,7 +329,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
const show_popover = useStorage<boolean>('show_popover', true); const show_popover = useStorage<boolean>('show_popover', true);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1'); const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ','); const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false); const { mode, on: modeOn, save: applyMode } = useMode();
const draftMode = ref<Mode>(mode.value);
const savingMode = ref(false);
const page_anims = useStorage<boolean>('page_anims', true); const page_anims = useStorage<boolean>('page_anims', true);
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true); const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000); const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
@ -338,10 +354,10 @@ const settingsSwitchUi = {
wrapper: 'ms-0 flex-1 text-sm', wrapper: 'ms-0 flex-1 text-sm',
}; };
const bgOpacityModel = computed<number>({ const bgVisibilityModel = computed<number>({
get: () => Number(bg_opacity.value), get: () => Number((1 - Number(bg_opacity.value)).toFixed(2)),
set: (value) => { set: (value) => {
bg_opacity.value = Number(value); bg_opacity.value = Number((1 - Number(value)).toFixed(2));
}, },
}); });
@ -356,6 +372,30 @@ const separatorItems = computed(() =>
separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })), separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })),
); );
const modeItems = computed<Array<{ label: string; value: Mode }>>(() => [
{
label: `Default`,
value: 'default',
},
{ label: 'Simple', value: 'simple' },
{ label: 'Regular', value: 'regular' },
]);
const modeChanged = computed(() => draftMode.value !== mode.value);
const saveMode = async (): Promise<void> => {
if (!modeChanged.value || savingMode.value) {
return;
}
savingMode.value = true;
try {
await applyMode(draftMode.value);
} finally {
savingMode.value = false;
}
};
const thumbnailRatioItems = [ const thumbnailRatioItems = [
{ label: '16:9', value: 'is-16by9' }, { label: '16:9', value: 'is-16by9' },
{ label: '3:1', value: 'is-3by1' }, { label: '3:1', value: 'is-3by1' },
@ -412,12 +452,21 @@ watch(
() => props.isOpen, () => props.isOpen,
(isOpen) => { (isOpen) => {
if (isOpen) { if (isOpen) {
draftMode.value = mode.value;
document.body.classList.add('settings-panel-open'); document.body.classList.add('settings-panel-open');
} else { } else {
document.body.classList.remove('settings-panel-open'); document.body.classList.remove('settings-panel-open');
} }
}, },
); );
watch(mode, (value) => {
if (props.isOpen && modeChanged.value) {
return;
}
draftMode.value = value;
});
</script> </script>
<style scoped> <style scoped>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
<template>
<UTooltip :text="title">
<UButton
color="neutral"
variant="ghost"
size="sm"
:icon="icon"
:square="square"
:aria-label="title"
:title="title"
@click="color.preference = next"
>
<span v-if="showLabel" :class="labelClass">{{ title }}</span>
</UButton>
</UTooltip>
</template>
<script setup lang="ts">
import { computed } from 'vue';
withDefaults(
defineProps<{
square?: boolean;
showLabel?: boolean;
labelClass?: string;
}>(),
{
square: false,
showLabel: true,
labelClass: '',
},
);
type Choice = 'system' | 'light' | 'dark';
const opts: Array<Choice> = ['system', 'light', 'dark'];
const color = useColorMode();
const current = computed<Choice>(() =>
opts.includes(color.preference as Choice) ? (color.preference as Choice) : 'system',
);
const next = computed<Choice>(
() => opts[(opts.indexOf(current.value) + 1) % opts.length] ?? 'system',
);
const icon = computed(() => {
if (current.value === 'light') {
return 'i-lucide-sun';
}
if (current.value === 'dark') {
return 'i-lucide-moon';
}
return 'i-lucide-monitor';
});
const title = computed(() => {
if (current.value === 'light') {
return 'Light';
}
if (current.value === 'dark') {
return 'Dark';
}
return 'System';
});
</script>

View file

@ -0,0 +1,130 @@
import { computed, nextTick, type ComputedRef, type WritableComputedRef } from 'vue';
import { useStorage, type RemovableRef } from '@vueuse/core';
export type Mode = 'default' | 'simple' | 'regular';
type Pref = boolean | null;
export const MODE_KEY = 'simple_mode';
export const SIMPLE_PATH = '/simple';
const YES = new Set(['1', 'true', 'yes', 'on']);
const NO = new Set(['0', 'false', 'no', 'off']);
let prefRef: RemovableRef<Pref> | null = null;
export const parseMode = (value: unknown): Pref => {
if (Array.isArray(value)) {
return parseMode(value[0]);
}
if ('boolean' === typeof value) {
return value;
}
if ('number' === typeof value) {
return value === 1 ? true : value === 0 ? false : null;
}
if ('string' !== typeof value) {
return null;
}
const item = value.trim().toLowerCase();
if (YES.has(item)) {
return true;
}
if (NO.has(item)) {
return false;
}
return null;
};
export const modeFromPref = (pref: Pref): Mode => {
if (pref === null) {
return 'default';
}
return pref ? 'simple' : 'regular';
};
export const prefFromMode = (mode: Mode): Pref => {
if (mode === 'default') {
return null;
}
return mode === 'simple';
};
export const isSimple = (pref: Pref, fallback: boolean): boolean => pref ?? fallback;
export const cleanPath = (path: string): string => path.replace(/\/+$/, '') || '/';
export const usePref = (): RemovableRef<Pref> => {
if (prefRef === null) {
prefRef = useStorage<Pref>(MODE_KEY, null, undefined, {
serializer: {
read: parseMode,
write: (value) => (value === null ? 'null' : String(value)),
},
writeDefaults: false,
});
}
return prefRef;
};
export const cookieDefault = (): Pref => parseMode(useCookie<string | null>(MODE_KEY).value);
export const savePref = (value: Pref): void => {
const pref = usePref();
pref.value = value;
};
export const useMode = (): {
mode: WritableComputedRef<Mode>;
on: ComputedRef<boolean>;
def: ComputedRef<boolean>;
save: (mode: Mode) => Promise<void>;
} => {
const cfg = useYtpConfig();
const route = useRoute();
const pref = usePref();
const def = computed(() => Boolean(cfg.is_loaded ? cfg.app.simple_mode : cookieDefault()));
const on = computed(() => isSimple(pref.value, def.value));
const save = async (value: Mode): Promise<void> => {
savePref(prefFromMode(value));
await nextTick();
const simpleRoute = cleanPath(route.path) === SIMPLE_PATH;
if (on.value && !simpleRoute) {
await navigateTo(SIMPLE_PATH);
return;
}
if (!on.value && simpleRoute) {
await navigateTo('/');
}
};
const mode = computed<Mode>({
get: () => modeFromPref(pref.value),
set: (value) => {
void save(value);
},
});
return { mode, on, def, save };
};
export const routeTarget = (path: string, pref: Pref, fallback: boolean): string | null => {
if (cleanPath(path) !== '/') {
return null;
}
return isSimple(pref, fallback) ? SIMPLE_PATH : null;
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
import { cookieDefault, parseMode, routeTarget, savePref, usePref } from '~/composables/useMode';
export default defineNuxtRouteMiddleware(async (to) => {
const path = to.path.replace(/\/+$/, '') || '/';
const pref = usePref();
if (to.query.simple !== undefined) {
const value = parseMode(to.query.simple);
if (value !== null) {
savePref(value);
}
const query = { ...to.query };
delete query.simple;
const target = value === true ? '/simple' : value === false ? '/' : to.path;
return navigateTo({ path: target, query, hash: to.hash }, { replace: true });
}
if (path !== '/') {
return;
}
let def = cookieDefault();
if (pref.value === null && def === null) {
const cfg = useYtpConfig();
await cfg.loadConfig();
def = Boolean(cfg.app.simple_mode);
}
const target = routeTarget(path, pref.value, Boolean(def));
if (target) {
return navigateTo(target, { replace: true });
}
});

View file

@ -754,7 +754,6 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
const config = useYtpConfig(); const config = useYtpConfig();
const stateStore = useQueueState(); const stateStore = useQueueState();
const socket = useAppSocket(); const socket = useAppSocket();
const route = useRoute();
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
@ -889,15 +888,6 @@ const stopAutoRefresh = (): void => {
}; };
onMounted(async () => { onMounted(async () => {
if (route.query?.simple !== undefined) {
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
await nextTick();
const url = new URL(window.location.href);
url.searchParams.delete('simple');
window.history.replaceState({}, '', url.toString());
}
if (Object.keys(pendingDownloadFormItem.value).length > 0) { if (Object.keys(pendingDownloadFormItem.value).length > 0) {
await toNewDownload(pendingDownloadFormItem.value); await toNewDownload(pendingDownloadFormItem.value);
pendingDownloadFormItem.value = {}; pendingDownloadFormItem.value = {};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import {
SIMPLE_PATH,
isSimple,
modeFromPref,
parseMode,
prefFromMode,
routeTarget,
} from '~/composables/useMode';
describe('useMode', () => {
it('parse', () => {
expect(parseMode('true')).toBe(true);
expect(parseMode('false')).toBe(false);
expect(parseMode(true)).toBe(true);
expect(parseMode(false)).toBe(false);
expect(parseMode('null')).toBeNull();
expect(parseMode('')).toBeNull();
});
it('resolve', () => {
expect(isSimple(null, true)).toBe(true);
expect(isSimple(null, false)).toBe(false);
expect(isSimple(false, true)).toBe(false);
expect(isSimple(true, false)).toBe(true);
});
it('map', () => {
expect(modeFromPref(null)).toBe('default');
expect(modeFromPref(true)).toBe('simple');
expect(modeFromPref(false)).toBe('regular');
expect(prefFromMode('default')).toBeNull();
expect(prefFromMode('simple')).toBe(true);
expect(prefFromMode('regular')).toBe(false);
});
it('route', () => {
expect(routeTarget('/', null, true)).toBe(SIMPLE_PATH);
expect(routeTarget('/', false, true)).toBeNull();
expect(routeTarget('/history', null, true)).toBeNull();
expect(routeTarget(SIMPLE_PATH, null, false)).toBeNull();
});
});