mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
bring over relevant desktop settings
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
markTrackPlayed,
|
||||
setSetting,
|
||||
} from '@/db/queries';
|
||||
import { markLocalTracksStaleForRebuild } from '@/db/libraryMaintenance';
|
||||
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
|
||||
import { buildAlbumList } from '@/library/albumSummary';
|
||||
import { ensureArtworkThumbnails } from '@/library/artwork';
|
||||
@@ -113,6 +114,7 @@ interface LibraryStore {
|
||||
addFolder: () => Promise<void>;
|
||||
removeFolder: (folderId: number) => Promise<void>;
|
||||
rescan: () => Promise<void>;
|
||||
rebuildLocalIndex: () => Promise<void>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
@@ -300,5 +302,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
||||
},
|
||||
|
||||
rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })),
|
||||
|
||||
rebuildLocalIndex: () => runScan(async () => {
|
||||
const db = await openLibraryDb();
|
||||
await markLocalTracksStaleForRebuild(db);
|
||||
return rescanAll({ callbacks: { onProgress } });
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import {
|
||||
DEFAULT_LYRICS_DISPLAY_SETTINGS,
|
||||
normalizeLyricsDisplaySettings,
|
||||
normalizeLyricsLanguagePriority,
|
||||
type LyricsDisplaySettings,
|
||||
} from '@/lyrics/displaySettings';
|
||||
|
||||
export {
|
||||
DEFAULT_LYRICS_DISPLAY_SETTINGS,
|
||||
DEFAULT_LYRICS_LANGUAGE_PRIORITY,
|
||||
normalizeLyricsDisplaySettings,
|
||||
normalizeLyricsLanguagePriority,
|
||||
type LyricsDisplaySettings,
|
||||
} from '@/lyrics/displaySettings';
|
||||
|
||||
const ONLINE_LOOKUP_KEY = 'lyrics_online_lookup_enabled';
|
||||
const DISPLAY_SETTINGS_KEY = 'lyrics_display_settings_v1';
|
||||
|
||||
interface LyricsSettingsStore extends LyricsDisplaySettings {
|
||||
onlineLookupEnabled: boolean;
|
||||
loaded: boolean;
|
||||
load: () => Promise<void>;
|
||||
setOnlineLookupEnabled: (enabled: boolean) => Promise<void>;
|
||||
setWordTimingEnabled: (enabled: boolean) => Promise<void>;
|
||||
setFuriganaEnabled: (enabled: boolean) => Promise<void>;
|
||||
setTranslationsEnabled: (enabled: boolean) => Promise<void>;
|
||||
setTranslationPriority: (value: string | string[]) => Promise<void>;
|
||||
setVoiceLabelsEnabled: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function displaySettingsFromState(state: LyricsSettingsStore): LyricsDisplaySettings {
|
||||
return {
|
||||
wordTimingEnabled: state.wordTimingEnabled,
|
||||
furiganaEnabled: state.furiganaEnabled,
|
||||
translationsEnabled: state.translationsEnabled,
|
||||
translationPriority: state.translationPriority,
|
||||
voiceLabelsEnabled: state.voiceLabelsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistDisplaySettings(settings: LyricsDisplaySettings): Promise<void> {
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
export const useLyricsSettingsStore = create<LyricsSettingsStore>((set, get) => ({
|
||||
onlineLookupEnabled: true,
|
||||
...DEFAULT_LYRICS_DISPLAY_SETTINGS,
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
const db = await openLibraryDb();
|
||||
const [onlineValue, displayValue] = await Promise.all([
|
||||
getSetting(db, ONLINE_LOOKUP_KEY),
|
||||
getSetting(db, DISPLAY_SETTINGS_KEY),
|
||||
]);
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = displayValue ? JSON.parse(displayValue) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
set({
|
||||
onlineLookupEnabled: onlineValue === null ? true : onlineValue === 'true',
|
||||
...normalizeLyricsDisplaySettings(parsed),
|
||||
loaded: true,
|
||||
});
|
||||
})().finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
setOnlineLookupEnabled: async (enabled) => {
|
||||
await get().load();
|
||||
set({ onlineLookupEnabled: enabled });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setWordTimingEnabled: async (enabled) => {
|
||||
await get().load();
|
||||
set({ wordTimingEnabled: enabled });
|
||||
await persistDisplaySettings({ ...displaySettingsFromState(get()), wordTimingEnabled: enabled });
|
||||
},
|
||||
|
||||
setFuriganaEnabled: async (enabled) => {
|
||||
await get().load();
|
||||
set({ furiganaEnabled: enabled });
|
||||
await persistDisplaySettings({ ...displaySettingsFromState(get()), furiganaEnabled: enabled });
|
||||
},
|
||||
|
||||
setTranslationsEnabled: async (enabled) => {
|
||||
await get().load();
|
||||
set({ translationsEnabled: enabled });
|
||||
await persistDisplaySettings({ ...displaySettingsFromState(get()), translationsEnabled: enabled });
|
||||
},
|
||||
|
||||
setTranslationPriority: async (value) => {
|
||||
await get().load();
|
||||
const translationPriority = normalizeLyricsLanguagePriority(value);
|
||||
set({ translationPriority });
|
||||
await persistDisplaySettings({ ...displaySettingsFromState(get()), translationPriority });
|
||||
},
|
||||
|
||||
setVoiceLabelsEnabled: async (enabled) => {
|
||||
await get().load();
|
||||
set({ voiceLabelsEnabled: enabled });
|
||||
await persistDisplaySettings({ ...displaySettingsFromState(get()), voiceLabelsEnabled: enabled });
|
||||
},
|
||||
}));
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics';
|
||||
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||
import type { LyricsLookupResult } from '@/lyrics/types';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
@@ -17,10 +18,9 @@ export interface LyricsUiEntry {
|
||||
}
|
||||
|
||||
interface LyricsStore {
|
||||
onlineEnabled: boolean;
|
||||
byPath: Record<string, LyricsUiEntry>;
|
||||
loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise<void>;
|
||||
setOnlineEnabled: (enabled: boolean) => void;
|
||||
invalidateAll: () => void;
|
||||
}
|
||||
|
||||
// Latest request id per path — guards against a stale response overwriting a
|
||||
@@ -40,11 +40,11 @@ function pruneToLru(byPath: Record<string, LyricsUiEntry>): Record<string, Lyric
|
||||
}
|
||||
|
||||
export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
||||
onlineEnabled: true,
|
||||
byPath: {},
|
||||
|
||||
loadForTrack: async (track, options = {}) => {
|
||||
if (!track?.path) return;
|
||||
await useLyricsSettingsStore.getState().load();
|
||||
const path = track.path;
|
||||
const force = Boolean(options.force);
|
||||
|
||||
@@ -74,7 +74,10 @@ export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
||||
|
||||
let result: LyricsLookupResult;
|
||||
try {
|
||||
result = await getLyricsForTrack(query, { forceRefresh: force, onlineEnabled: get().onlineEnabled });
|
||||
result = await getLyricsForTrack(query, {
|
||||
forceRefresh: force,
|
||||
onlineEnabled: useLyricsSettingsStore.getState().onlineLookupEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
result = {
|
||||
status: 'transient_error',
|
||||
@@ -90,5 +93,8 @@ export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
setOnlineEnabled: (enabled) => set({ onlineEnabled: enabled }),
|
||||
invalidateAll: () => {
|
||||
requestIds.clear();
|
||||
set({ byPath: {} });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import { setPauseAtEndOfItem } from '@/audio/trackPlayerExtensions';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import {
|
||||
getSleepTimerRemainingMs,
|
||||
normalizePersistedSleepTimer,
|
||||
normalizeSleepTimerMinutes,
|
||||
shouldCompleteEndOfTrackTimer,
|
||||
transitionSleepTimer,
|
||||
type PersistedSleepTimerState,
|
||||
} from '@/audio/sleepTimerState';
|
||||
|
||||
const SLEEP_TIMER_KEY = 'sleep_timer_state_v1';
|
||||
|
||||
interface SleepTimerStore {
|
||||
timer: PersistedSleepTimerState | null;
|
||||
remainingMs: number | null;
|
||||
hydrated: boolean;
|
||||
hydrate: () => Promise<void>;
|
||||
startMinutes: (minutes: number) => Promise<void>;
|
||||
startEndOfTrack: () => Promise<void>;
|
||||
cancel: () => Promise<void>;
|
||||
reconcile: (nowMs?: number) => Promise<void>;
|
||||
reconcileEndOfTrack: (position: number, duration: number, playWhenReady: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hydrationPromise: Promise<void> | null = null;
|
||||
let reconcilePromise: Promise<void> | null = null;
|
||||
|
||||
function clearDeadlineTimer(): void {
|
||||
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
|
||||
deadlineTimer = null;
|
||||
}
|
||||
|
||||
async function persistTimer(timer: PersistedSleepTimerState | null): Promise<void> {
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : '');
|
||||
}
|
||||
|
||||
async function hasActivePhoneTrack(): Promise<boolean> {
|
||||
try {
|
||||
return Boolean(await TrackPlayer.getActiveTrack());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDeadline(timer: PersistedSleepTimerState | null): void {
|
||||
clearDeadlineTimer();
|
||||
if (timer?.mode !== 'minutes' || timer.expiresAtMs === null) return;
|
||||
const delay = Math.max(0, timer.expiresAtMs - Date.now());
|
||||
deadlineTimer = setTimeout(() => {
|
||||
deadlineTimer = null;
|
||||
void useSleepTimerStore.getState().reconcile();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function clearCompletedTimer(expectedTimer?: PersistedSleepTimerState): Promise<void> {
|
||||
if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return;
|
||||
clearDeadlineTimer();
|
||||
await setPauseAtEndOfItem(false).catch(() => {});
|
||||
if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return;
|
||||
useSleepTimerStore.setState({ timer: null, remainingMs: null, hydrated: true });
|
||||
await persistTimer(null);
|
||||
}
|
||||
|
||||
export const useSleepTimerStore = create<SleepTimerStore>((set, get) => ({
|
||||
timer: null,
|
||||
remainingMs: null,
|
||||
hydrated: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
if (hydrationPromise) return hydrationPromise;
|
||||
hydrationPromise = (async () => {
|
||||
const db = await openLibraryDb();
|
||||
const raw = await getSetting(db, SLEEP_TIMER_KEY);
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const timer = normalizePersistedSleepTimer(parsed);
|
||||
const active = timer ? await hasActivePhoneTrack() : false;
|
||||
if (!timer || !active) {
|
||||
set({ timer: null, remainingMs: null, hydrated: true });
|
||||
if (raw) await persistTimer(null);
|
||||
return;
|
||||
}
|
||||
set({
|
||||
timer,
|
||||
remainingMs: getSleepTimerRemainingMs(timer, Date.now()),
|
||||
hydrated: true,
|
||||
});
|
||||
if (timer.mode === 'end-of-track') await setPauseAtEndOfItem(true);
|
||||
scheduleDeadline(timer);
|
||||
await get().reconcile();
|
||||
})().finally(() => {
|
||||
hydrationPromise = null;
|
||||
});
|
||||
return hydrationPromise;
|
||||
},
|
||||
|
||||
startMinutes: async (value) => {
|
||||
const minutes = normalizeSleepTimerMinutes(value);
|
||||
if (minutes === null) throw new Error('Choose a whole number from 1 to 720 minutes.');
|
||||
if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.');
|
||||
if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.');
|
||||
await setPauseAtEndOfItem(false).catch(() => {});
|
||||
const timer = transitionSleepTimer(get().timer, { type: 'start-minutes', minutes }, Date.now());
|
||||
if (!timer) throw new Error('Choose a whole number from 1 to 720 minutes.');
|
||||
set({ timer, remainingMs: minutes * 60_000, hydrated: true });
|
||||
await persistTimer(timer);
|
||||
scheduleDeadline(timer);
|
||||
},
|
||||
|
||||
startEndOfTrack: async () => {
|
||||
if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.');
|
||||
if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.');
|
||||
await setPauseAtEndOfItem(true);
|
||||
clearDeadlineTimer();
|
||||
const timer = transitionSleepTimer(get().timer, { type: 'start-end-of-track' }, Date.now());
|
||||
set({ timer, remainingMs: null, hydrated: true });
|
||||
await persistTimer(timer);
|
||||
},
|
||||
|
||||
cancel: async () => {
|
||||
await clearCompletedTimer();
|
||||
},
|
||||
|
||||
reconcile: async (nowMs = Date.now()) => {
|
||||
const timer = get().timer;
|
||||
if (!timer) return;
|
||||
if (timer.mode === 'end-of-track') return;
|
||||
const remainingMs = getSleepTimerRemainingMs(timer, nowMs) ?? 0;
|
||||
set({ remainingMs });
|
||||
if (remainingMs > 0) {
|
||||
scheduleDeadline(timer);
|
||||
return;
|
||||
}
|
||||
if (reconcilePromise) return reconcilePromise;
|
||||
reconcilePromise = (async () => {
|
||||
await TrackPlayer.pause().catch(() => {});
|
||||
await clearCompletedTimer(timer);
|
||||
})().finally(() => {
|
||||
reconcilePromise = null;
|
||||
});
|
||||
return reconcilePromise;
|
||||
},
|
||||
|
||||
reconcileEndOfTrack: async (position, duration, playWhenReady) => {
|
||||
const timer = get().timer;
|
||||
if (!shouldCompleteEndOfTrackTimer(timer, position, duration, playWhenReady) || !timer) return;
|
||||
await clearCompletedTimer(timer);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user