feat(settings): General-first layout + lightbox video playback toggles
Reorder the Settings sections so General is the top and default section instead of AI Workspace. Add two persisted settings under a new "Video playback" group: - Autoplay in lightbox (default on) - Start muted (default off) VideoPlayer reads these when a video opens — autoplaying only when enabled and starting muted when enabled, otherwise falling back to the session's last-used mute state. Settings apply to the next opened video, not the current one.
This commit is contained in:
@@ -6,8 +6,8 @@ import { ThemedDropdown } from "./ThemedDropdown";
|
|||||||
type SettingsSection = "workspace" | "general";
|
type SettingsSection = "workspace" | "general";
|
||||||
|
|
||||||
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||||
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
|
||||||
{ id: "general", label: "General", detail: "App data and diagnostics" },
|
{ id: "general", label: "General", detail: "App data and diagnostics" },
|
||||||
|
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatBytesShort(bytes: number): string {
|
function formatBytesShort(bytes: number): string {
|
||||||
@@ -122,7 +122,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal() {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
|
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
|
||||||
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
||||||
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
||||||
const [taggerClearing, setTaggerClearing] = useState(false);
|
const [taggerClearing, setTaggerClearing] = useState(false);
|
||||||
@@ -195,6 +195,10 @@ export function SettingsModal() {
|
|||||||
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
||||||
const theme = useGalleryStore((state) => state.theme);
|
const theme = useGalleryStore((state) => state.theme);
|
||||||
const setTheme = useGalleryStore((state) => state.setTheme);
|
const setTheme = useGalleryStore((state) => state.setTheme);
|
||||||
|
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
|
||||||
|
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
|
||||||
|
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
|
||||||
|
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsOpen) return;
|
if (!settingsOpen) return;
|
||||||
@@ -618,6 +622,35 @@ export function SettingsModal() {
|
|||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Video playback">
|
||||||
|
<SettingsItem
|
||||||
|
label="Autoplay in lightbox"
|
||||||
|
description="Start playing videos automatically when opened in the lightbox."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={lightboxAutoplay}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
|
onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
<SettingsItem
|
||||||
|
label="Start muted"
|
||||||
|
description="Open videos with their audio muted — unmute from the player controls."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={lightboxAutoMute}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
|
onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup title="Updates">
|
<SettingsGroup title="Updates">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
label={
|
label={
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
const CONTROLS_HIDE_DELAY_MS = 2500;
|
const CONTROLS_HIDE_DELAY_MS = 2500;
|
||||||
@@ -57,7 +58,9 @@ export function VideoPlayer({ src }: { src: string }) {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
||||||
const [volume, setVolume] = useState(persistedVolume);
|
const [volume, setVolume] = useState(persistedVolume);
|
||||||
const [muted, setMuted] = useState(persistedMuted);
|
const [muted, setMuted] = useState(
|
||||||
|
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
|
||||||
|
);
|
||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
const [loop, setLoop] = useState(false);
|
const [loop, setLoop] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
@@ -89,11 +92,16 @@ export function VideoPlayer({ src }: { src: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
// Read playback prefs fresh for each opened video — not reactive, so a
|
||||||
|
// settings change applies to the next video rather than the current one.
|
||||||
|
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
|
||||||
|
const startMuted = lightboxAutoMute || persistedMuted;
|
||||||
video.volume = persistedVolume;
|
video.volume = persistedVolume;
|
||||||
video.muted = persistedMuted;
|
video.muted = startMuted;
|
||||||
// Autoplay; if the webview blocks it the catch leaves us paused with
|
setMuted(startMuted);
|
||||||
// controls visible, which is a fine fallback.
|
// Autoplay when enabled; if the webview blocks it the catch leaves us paused
|
||||||
video.play().catch(() => {});
|
// with controls visible, which is a fine fallback.
|
||||||
|
if (lightboxAutoplay) video.play().catch(() => {});
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
const readBuffered = useCallback(() => {
|
const readBuffered = useCallback(() => {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { notifyTaskComplete } from "./notifications";
|
|||||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
||||||
const THEME_KEY = "phokus-theme";
|
const THEME_KEY = "phokus-theme";
|
||||||
|
const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay";
|
||||||
|
const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute";
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -346,6 +348,8 @@ interface GalleryState {
|
|||||||
mutedFolderIds: number[];
|
mutedFolderIds: number[];
|
||||||
notificationsPaused: boolean;
|
notificationsPaused: boolean;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
|
lightboxAutoplay: boolean;
|
||||||
|
lightboxAutoMute: boolean;
|
||||||
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
||||||
// bar and the sidebar folder context menu.
|
// bar and the sidebar folder context menu.
|
||||||
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
||||||
@@ -445,6 +449,8 @@ interface GalleryState {
|
|||||||
loadNotificationsPaused: () => Promise<void>;
|
loadNotificationsPaused: () => Promise<void>;
|
||||||
setNotificationsPaused: (paused: boolean) => void;
|
setNotificationsPaused: (paused: boolean) => void;
|
||||||
setTheme: (theme: AppTheme) => void;
|
setTheme: (theme: AppTheme) => void;
|
||||||
|
setLightboxAutoplay: (enabled: boolean) => void;
|
||||||
|
setLightboxAutoMute: (enabled: boolean) => void;
|
||||||
loadWorkerStates: () => Promise<void>;
|
loadWorkerStates: () => Promise<void>;
|
||||||
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
||||||
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
||||||
@@ -515,6 +521,12 @@ function initialAiCaptionsEnabled(): boolean {
|
|||||||
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialBoolSetting(key: string, fallback: boolean): boolean {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const stored = window.localStorage.getItem(key);
|
||||||
|
return stored === null ? fallback : stored === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function initialTheme(): AppTheme {
|
function initialTheme(): AppTheme {
|
||||||
if (typeof window === "undefined") return "phokus";
|
if (typeof window === "undefined") return "phokus";
|
||||||
const saved = window.localStorage.getItem(THEME_KEY);
|
const saved = window.localStorage.getItem(THEME_KEY);
|
||||||
@@ -765,6 +777,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
mutedFolderIds: [],
|
mutedFolderIds: [],
|
||||||
notificationsPaused: false,
|
notificationsPaused: false,
|
||||||
theme: initialTheme(),
|
theme: initialTheme(),
|
||||||
|
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
|
||||||
|
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
|
||||||
workerPaused: {},
|
workerPaused: {},
|
||||||
|
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
@@ -1649,6 +1663,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
set({ theme });
|
set({ theme });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setLightboxAutoplay: (enabled) => {
|
||||||
|
window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled));
|
||||||
|
set({ lightboxAutoplay: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
setLightboxAutoMute: (enabled) => {
|
||||||
|
window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled));
|
||||||
|
set({ lightboxAutoMute: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
loadWorkerStates: async () => {
|
loadWorkerStates: async () => {
|
||||||
const folderIds = get().folders.map((folder) => folder.id);
|
const folderIds = get().folders.map((folder) => folder.id);
|
||||||
if (folderIds.length === 0) {
|
if (folderIds.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user