🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.
Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.
Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.
Two things the tests caught that review would not have:
- redact_headers recursed on its own output. The replacement keeps the
header NAME, so the next call matched the same header forever; the
test died with a stack overflow. It is a forward scan now.
- The frontend forwarder used `void plugin.error(...)`. `void` discards
a promise's value but not its rejection, so in any webview without
IPC -- a unit test, SSR, a browser preview -- every log line became an
unhandled rejection. 20 of them showed up the first time coverage
ran. Each call now attaches a catch.
Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.
Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.
The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.
Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.
Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
1427 lines
56 KiB
Svelte
1427 lines
56 KiB
Svelte
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
|
|
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { commands } from "$lib/api/bindings";
|
|
import type {
|
|
AudioSettings,
|
|
CacheConfig,
|
|
EqPreset,
|
|
ExclusionCandidate,
|
|
LibrarySettings,
|
|
StreamingQuality,
|
|
VideoSettings,
|
|
VolumeLevel,
|
|
DiagnosticsInfo,
|
|
DiagnosticsBundle,
|
|
} from "$lib/api/bindings";
|
|
import {
|
|
getCacheStats,
|
|
setCacheLimit,
|
|
clearCache,
|
|
formatBytes,
|
|
gbToBytes,
|
|
bytesToGb,
|
|
type ImageCacheStats,
|
|
} from "$lib/services/imageCache";
|
|
import { getCacheConfig, updateCacheConfig } from "$lib/services/preload";
|
|
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
|
|
import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
|
|
import { library, viewMode } from "$lib/stores/library";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
|
|
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
import {
|
|
checkForUpdate,
|
|
installUpdate,
|
|
updateCapability,
|
|
RELEASES_URL,
|
|
type UpdateAction,
|
|
} from "$lib/utils/updateCheck";
|
|
|
|
const log = createLogger("SettingsPage");
|
|
|
|
const episodeLimitOptions = [
|
|
{ value: 0, label: "Unlimited" },
|
|
{ value: 1, label: "1" },
|
|
{ value: 2, label: "2" },
|
|
{ value: 3, label: "3" },
|
|
{ value: 5, label: "5" },
|
|
{ value: 10, label: "10" },
|
|
];
|
|
|
|
let settings = $state<AudioSettings>({
|
|
crossfadeDuration: 0,
|
|
gaplessPlayback: true,
|
|
normalizeVolume: false,
|
|
volumeLevel: "normal",
|
|
equalizerEnabled: false,
|
|
equalizerBands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
});
|
|
|
|
// Equalizer band centre-frequency labels (must match Rust EQ_BANDS order).
|
|
// Presentation only — the gain curves themselves come from the backend.
|
|
const EQ_BAND_LABELS = ["31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
|
|
const EQ_GAIN_MIN = -12;
|
|
const EQ_GAIN_MAX = 12;
|
|
// Preset name → gain curve, fetched from the backend (domain data lives in Rust).
|
|
let eqPresets = $state<[EqPreset, number[]][]>([]);
|
|
// Non-optional view of the bands for template bindings (the wire type marks
|
|
// equalizerBands optional via serde default; loadSettings guarantees it dense).
|
|
const eqBands = $derived(settings.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
|
|
let videoSettings = $state<VideoSettings>({
|
|
autoPlayNextEpisode: true,
|
|
autoPlayCountdownSeconds: 10,
|
|
autoPlayMaxEpisodes: 0,
|
|
streamingQuality: "original",
|
|
});
|
|
|
|
// Bandwidth ceilings offered by the streaming-quality picker, as
|
|
// [variant, label, detail] — the numbers behind each step are Jellyfin
|
|
// encoding vocabulary, so Rust serves the list. TRACES: UR-074 | DR-162
|
|
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
|
|
|
// Download/caching behaviour, incl. the WiFi-only gate (UR-053).
|
|
let cacheConfig = $state<CacheConfig>({
|
|
queuePrecacheEnabled: true,
|
|
queuePrecacheCount: 3,
|
|
albumAffinityEnabled: true,
|
|
albumAffinityThreshold: 3,
|
|
storageLimit: 10 * 1024 * 1024 * 1024,
|
|
wifiOnly: false,
|
|
// Placeholder only — replaced by the backend's value on load. How long a
|
|
// temporary (auto-cached) download lives before it is reclaimed; the policy
|
|
// itself is Rust's (DR-127).
|
|
temporaryTtlHours: 24 * 7,
|
|
});
|
|
|
|
// Folders the user has hidden from browsing, and the folders they may choose
|
|
// from. Both come from Rust: which containers are offerable, and what hiding
|
|
// one actually excludes, are domain decisions — this page only renders the
|
|
// list and sends back the ids that are ticked.
|
|
// TRACES: UR-076 | DR-209
|
|
let librarySettings = $state<LibrarySettings>({ excludedItemIds: [] });
|
|
let exclusionCandidates = $state<ExclusionCandidate[]>([]);
|
|
let exclusionsLoading = $state(false);
|
|
const excludedIds = $derived(new Set(librarySettings.excludedItemIds ?? []));
|
|
|
|
// Whether the platform can actually detect the network type. On desktop it
|
|
// can't, so the WiFi-only toggle would be inert — we disable and explain it
|
|
// rather than offering a switch that does nothing.
|
|
let networkDetectionSupported = $state(false);
|
|
|
|
let loading = $state(true);
|
|
|
|
// Image cache state
|
|
let cacheStats = $state<ImageCacheStats | null>(null);
|
|
let cacheLoading = $state(false);
|
|
let clearingCache = $state(false);
|
|
|
|
// Cache limit options in bytes
|
|
const cacheLimitOptions = [
|
|
{ label: "500 MB", bytes: gbToBytes(0.5) },
|
|
{ label: "1 GB", bytes: gbToBytes(1), default: true },
|
|
{ label: "2 GB", bytes: gbToBytes(2) },
|
|
{ label: "5 GB", bytes: gbToBytes(5) },
|
|
{ label: "Unlimited", bytes: 0 },
|
|
];
|
|
|
|
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
|
|
// owns the "does this platform have a native video surface" decision; the
|
|
// toggle is hidden entirely where it cannot apply.
|
|
let supportsNativeVideo = $state(false);
|
|
let nativeVideoEnabled = $state(false);
|
|
|
|
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
|
|
nativeVideoEnabled = v;
|
|
});
|
|
|
|
function handleNativeVideoToggle() {
|
|
experimentalNativeVideo.set(!nativeVideoEnabled);
|
|
}
|
|
|
|
// Not returned from onMount: that callback is async, so its return value is a
|
|
// Promise and Svelte would never invoke it as a teardown.
|
|
onDestroy(unsubscribeNativeVideo);
|
|
|
|
onMount(async () => {
|
|
await loadSettings();
|
|
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
|
|
|
// Which update story this platform gets. Android cannot install its own
|
|
// APK, so it is offered the releases page instead of an install button.
|
|
const { platform } = await import("@tauri-apps/plugin-os");
|
|
canInstallUpdates = updateCapability(platform()) === "install";
|
|
await loadDiagnostics();
|
|
});
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
loading = true;
|
|
networkDetectionSupported = isNetworkDetectionSupported();
|
|
const [audioResult, videoResult, cacheResult, presets, qualities, libraryResult] =
|
|
await Promise.all([
|
|
commands.playerGetAudioSettings(),
|
|
commands.playerGetVideoSettings(),
|
|
getCacheConfig(),
|
|
commands.playerGetEqPresets(),
|
|
commands.playerGetStreamingQualities(),
|
|
commands.libraryGetSettings(),
|
|
]);
|
|
librarySettings = libraryResult;
|
|
// equalizerBands is optional on the wire (serde default); guarantee a
|
|
// dense 10-band array so the slider bindings are never undefined.
|
|
settings = {
|
|
...audioResult,
|
|
equalizerBands: audioResult.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
};
|
|
videoSettings = videoResult;
|
|
cacheConfig = cacheResult;
|
|
eqPresets = presets;
|
|
streamingQualities = qualities;
|
|
// Load cache stats and the folder picker in parallel but don't block on
|
|
// either — both need a round trip the rest of the page doesn't.
|
|
loadCacheStats();
|
|
loadExclusionCandidates();
|
|
} catch (e) {
|
|
log.error("Failed to load settings:", e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ask the backend which folders may be hidden. Needs a live repository, so it
|
|
* quietly renders nothing when signed out rather than erroring on a page that
|
|
* is otherwise perfectly usable offline.
|
|
*
|
|
* TRACES: UR-076 | DR-209
|
|
*/
|
|
async function loadExclusionCandidates() {
|
|
try {
|
|
exclusionsLoading = true;
|
|
const handle = auth.getRepository().getHandle();
|
|
exclusionCandidates = await commands.libraryGetExclusionCandidates(handle);
|
|
} catch (e) {
|
|
log.warn("Failed to load library folders:", e);
|
|
exclusionCandidates = [];
|
|
} finally {
|
|
exclusionsLoading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tick or untick one folder. The backend returns the list it actually stored,
|
|
* so the picker shows what is in force rather than what was requested.
|
|
*
|
|
* TRACES: UR-076 | DR-209
|
|
*/
|
|
async function toggleExcludedItem(itemId: string) {
|
|
const current = librarySettings.excludedItemIds ?? [];
|
|
const next = current.includes(itemId)
|
|
? current.filter((id) => id !== itemId)
|
|
: [...current, itemId];
|
|
// Optimistic, so the checkbox doesn't lag a round trip behind the tap.
|
|
librarySettings = { ...librarySettings, excludedItemIds: next };
|
|
try {
|
|
librarySettings = await commands.librarySetSettings({ excludedItemIds: next });
|
|
} catch (e) {
|
|
log.error("Failed to save hidden folders:", e);
|
|
librarySettings = { ...librarySettings, excludedItemIds: current };
|
|
}
|
|
}
|
|
|
|
async function loadCacheStats() {
|
|
try {
|
|
cacheLoading = true;
|
|
cacheStats = await getCacheStats();
|
|
} catch (e) {
|
|
log.error("Failed to load cache stats:", e);
|
|
} finally {
|
|
cacheLoading = false;
|
|
}
|
|
}
|
|
|
|
async function handleCacheLimitChange(limitBytes: number) {
|
|
try {
|
|
await setCacheLimit(limitBytes);
|
|
// Reload stats to reflect new limit
|
|
await loadCacheStats();
|
|
} catch (e) {
|
|
log.error("Failed to set cache limit:", e);
|
|
}
|
|
}
|
|
|
|
async function handleClearCache() {
|
|
try {
|
|
clearingCache = true;
|
|
await clearCache();
|
|
await loadCacheStats();
|
|
} catch (e) {
|
|
log.error("Failed to clear cache:", e);
|
|
} finally {
|
|
clearingCache = false;
|
|
}
|
|
}
|
|
|
|
// Get the current selected limit option
|
|
function isCurrentLimit(optionBytes: number): boolean {
|
|
if (!cacheStats) return false;
|
|
// Unlimited is 0
|
|
if (optionBytes === 0 && cacheStats.limitBytes === 0) return true;
|
|
// Allow small tolerance for floating point
|
|
return Math.abs(cacheStats.limitBytes - optionBytes) < 1000;
|
|
}
|
|
|
|
// Calculate cache usage percentage
|
|
function getCacheUsagePercent(): number {
|
|
if (!cacheStats || cacheStats.limitBytes === 0) return 0;
|
|
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
|
}
|
|
|
|
// Settings apply the moment the user changes a control — there is no Save
|
|
// button. Each helper writes just the settings group it owns so a single
|
|
// toggle doesn't re-push unrelated state.
|
|
async function persistAudio() {
|
|
try {
|
|
await commands.playerSetAudioSettings(settings);
|
|
} catch (e) {
|
|
log.error("Failed to save audio settings:", e);
|
|
}
|
|
}
|
|
|
|
async function persistVideo() {
|
|
try {
|
|
await commands.playerSetVideoSettings(videoSettings);
|
|
} catch (e) {
|
|
log.error("Failed to save video settings:", e);
|
|
}
|
|
}
|
|
|
|
async function persistCache() {
|
|
try {
|
|
await updateCacheConfig(cacheConfig);
|
|
// Re-report the network so the backend re-evaluates the gate against the
|
|
// just-changed wifi-only preference, releasing or holding the queue now
|
|
// rather than at the next network change.
|
|
await reportNetworkState();
|
|
} catch (e) {
|
|
log.error("Failed to save download settings:", e);
|
|
}
|
|
}
|
|
|
|
// Slider drags fire `input` on every tick; update the live display there but
|
|
// only persist on `change` (pointer release) so we don't spam the backend.
|
|
function handleCrossfadeInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
settings.crossfadeDuration = parseFloat(target.value);
|
|
}
|
|
|
|
function handleCrossfadeChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
settings.crossfadeDuration = parseFloat(target.value);
|
|
persistAudio();
|
|
}
|
|
|
|
function handleGaplessToggle() {
|
|
settings.gaplessPlayback = !settings.gaplessPlayback;
|
|
persistAudio();
|
|
}
|
|
|
|
function handleNormalizeToggle() {
|
|
settings.normalizeVolume = !settings.normalizeVolume;
|
|
persistAudio();
|
|
}
|
|
|
|
function handleVolumeLevelChange(level: VolumeLevel) {
|
|
settings.volumeLevel = level;
|
|
persistAudio();
|
|
}
|
|
|
|
// --- Equalizer (UR-027) ---
|
|
|
|
function handleEqToggle() {
|
|
settings.equalizerEnabled = !settings.equalizerEnabled;
|
|
persistAudio();
|
|
}
|
|
|
|
// Apply a preset's gain curve (from the backend) to the bands.
|
|
function handleEqPreset(gains: number[]) {
|
|
settings.equalizerBands = [...gains];
|
|
persistAudio();
|
|
}
|
|
|
|
// Live-update a single band while dragging; persist on release (change).
|
|
function handleEqBandInput(index: number, e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const bands = [...eqBands];
|
|
bands[index] = parseFloat(target.value);
|
|
settings.equalizerBands = bands;
|
|
}
|
|
|
|
function handleEqBandChange(index: number, e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const bands = [...eqBands];
|
|
bands[index] = parseFloat(target.value);
|
|
settings.equalizerBands = bands;
|
|
persistAudio();
|
|
}
|
|
|
|
// The name of the preset whose curve matches the current bands, or null
|
|
// ("Custom"). Presentation-only label — the backend defines the curves.
|
|
const activeEqPreset = $derived.by<EqPreset | null>(() => {
|
|
const eq = eqBands;
|
|
for (const [name, gains] of eqPresets) {
|
|
if (gains.length === eq.length && gains.every((g, i) => g === eq[i])) {
|
|
return name;
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Human labels for preset chips.
|
|
const EQ_PRESET_LABELS: Record<EqPreset, string> = {
|
|
flat: "Flat",
|
|
rock: "Rock",
|
|
pop: "Pop",
|
|
jazz: "Jazz",
|
|
classical: "Classical",
|
|
bassBoost: "Bass Boost",
|
|
trebleBoost: "Treble Boost",
|
|
vocal: "Vocal",
|
|
};
|
|
|
|
function handleAutoPlayToggle() {
|
|
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
|
persistVideo();
|
|
}
|
|
|
|
function handleCountdownInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
|
}
|
|
|
|
function handleCountdownChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
|
persistVideo();
|
|
}
|
|
|
|
function handleEpisodeLimitChange(value: number) {
|
|
videoSettings.autoPlayMaxEpisodes = value;
|
|
persistVideo();
|
|
}
|
|
|
|
/** TRACES: UR-074 | DR-162 */
|
|
function handleStreamingQualityChange(quality: StreamingQuality) {
|
|
videoSettings.streamingQuality = quality;
|
|
persistVideo();
|
|
}
|
|
|
|
function handleSmartCachingToggle() {
|
|
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
|
|
persistCache();
|
|
}
|
|
|
|
function handleQueuePrecacheToggle() {
|
|
cacheConfig.queuePrecacheEnabled = !cacheConfig.queuePrecacheEnabled;
|
|
persistCache();
|
|
}
|
|
|
|
function handleWifiOnlyToggle() {
|
|
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
|
|
persistCache();
|
|
}
|
|
// ---------------------------------------------------------------------
|
|
// Updates
|
|
//
|
|
// The decision of *what to offer* lives in $lib/utils/updateCheck.ts and is
|
|
// unit-tested there; this component only renders the answer. On Android the
|
|
// answer is always "open the releases page" -- the updater plugin is not
|
|
// compiled for that target at all.
|
|
//
|
|
// TRACES: UR-077 | DR-217
|
|
let updateState = $state<"idle" | "checking" | "current" | "available" | "installing" | "failed">(
|
|
"idle",
|
|
);
|
|
let updateAction = $state<UpdateAction>({ kind: "none" });
|
|
let updateProgress = $state(0);
|
|
let canInstallUpdates = $state(true);
|
|
|
|
async function handleCheckForUpdates() {
|
|
updateState = "checking";
|
|
try {
|
|
if (!canInstallUpdates) {
|
|
// Nothing to interrogate on mobile; go straight to the download page.
|
|
await openUrl(RELEASES_URL);
|
|
updateState = "idle";
|
|
return;
|
|
}
|
|
const action = await checkForUpdate();
|
|
updateAction = action;
|
|
updateState = action.kind === "none" ? "current" : "available";
|
|
} catch (e) {
|
|
log.warn("update check failed", e);
|
|
updateState = "failed";
|
|
}
|
|
}
|
|
|
|
async function handleInstallUpdate() {
|
|
updateState = "installing";
|
|
updateProgress = 0;
|
|
try {
|
|
// installUpdate relaunches the app on success, so there is deliberately
|
|
// no "done" state here -- the process is gone before we could set one.
|
|
await installUpdate((fraction) => {
|
|
updateProgress = fraction;
|
|
});
|
|
} catch (e) {
|
|
log.error("update install failed", e);
|
|
updateState = "failed";
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------
|
|
// Diagnostics
|
|
//
|
|
// The app used to forget everything it did on exit, which is why several
|
|
// playback bugs here needed multiple rounds of "can you reproduce it under
|
|
// adb logcat". Logs are now on disk, redacted, and exportable as one file to
|
|
// attach to a bug report. Nothing is transmitted anywhere.
|
|
//
|
|
// TRACES: UR-078 | DR-218
|
|
let diagnosticsInfo = $state<DiagnosticsInfo | null>(null);
|
|
let exportedBundle = $state<DiagnosticsBundle | null>(null);
|
|
let exporting = $state(false);
|
|
let exportError = $state<string | null>(null);
|
|
|
|
const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const;
|
|
|
|
async function loadDiagnostics() {
|
|
try {
|
|
diagnosticsInfo = await commands.diagnosticsGetInfo();
|
|
} catch (e) {
|
|
// A settings page that cannot read the log level is still a usable
|
|
// settings page.
|
|
log.warn("Failed to read diagnostics info:", e);
|
|
}
|
|
}
|
|
|
|
async function handleLogLevelChange(level: string) {
|
|
try {
|
|
await commands.diagnosticsSetLevel(level);
|
|
await loadDiagnostics();
|
|
} catch (e) {
|
|
log.error("Failed to set log level:", e);
|
|
}
|
|
}
|
|
|
|
async function handleExportDiagnostics() {
|
|
exporting = true;
|
|
exportError = null;
|
|
exportedBundle = null;
|
|
try {
|
|
// The server URL is passed so the backend can record its scheme and host
|
|
// in the bundle. It reduces it to those two parts — the token that may be
|
|
// on the URL never reaches the file.
|
|
const serverUrl = auth.getServerUrl();
|
|
exportedBundle = await commands.diagnosticsExport(serverUrl);
|
|
await loadDiagnostics();
|
|
} catch (e) {
|
|
log.error("Diagnostics export failed:", e);
|
|
exportError = String(e);
|
|
} finally {
|
|
exporting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
|
|
<p class="text-gray-400">Configure display, playback, and downloads</p>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="text-center py-12 text-gray-400">
|
|
<p>Loading settings...</p>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-6">
|
|
<!-- Display — grid/list preference, a second view onto the library
|
|
viewMode store (same source of truth as the library page-header
|
|
toggle, so the two stay in sync for free). -->
|
|
<div id="display" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="mb-4">
|
|
<h2 class="text-xl font-semibold text-white">Display</h2>
|
|
<p class="text-sm text-gray-400 mt-1">How your library and collections are laid out</p>
|
|
</div>
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Layout</p>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button
|
|
onclick={() => library.setViewMode("grid")}
|
|
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
|
|
'grid'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={$viewMode === "grid"}
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z" />
|
|
</svg>
|
|
<span class="font-semibold">Grid</span>
|
|
</button>
|
|
<button
|
|
onclick={() => library.setViewMode("list")}
|
|
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
|
|
'list'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={$viewMode === "list"}
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z" />
|
|
</svg>
|
|
<span class="font-semibold">List</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden folders — music libraries often hold a folder of something the
|
|
user doesn't think of as music (podcasts, audiobooks, sound effects),
|
|
which otherwise turns up in every album, artist and track listing.
|
|
The candidate list and the meaning of "hidden" both come from Rust.
|
|
TRACES: UR-076 | DR-209 -->
|
|
<div id="hidden-folders" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="mb-4">
|
|
<h2 class="text-xl font-semibold text-white">Hidden Folders</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Folders to leave out of music browsing and search. Useful when a music library also
|
|
holds podcasts or audiobooks. Hidden folders can still be opened from a direct link, and
|
|
anything already playing or downloaded is unaffected.
|
|
</p>
|
|
</div>
|
|
|
|
{#if exclusionsLoading}
|
|
<p class="text-sm text-gray-400">Loading folders...</p>
|
|
{:else if exclusionCandidates.length === 0}
|
|
<p class="text-sm text-gray-400">
|
|
No music folders to choose from. Connect to your server to pick folders to hide.
|
|
</p>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
{#each exclusionCandidates as candidate (candidate.id)}
|
|
<button
|
|
onclick={() => toggleExcludedItem(candidate.id)}
|
|
class="w-full flex items-center justify-between gap-3 py-3 px-4 rounded-lg text-left transition-all {excludedIds.has(
|
|
candidate.id,
|
|
)
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={excludedIds.has(candidate.id)}
|
|
>
|
|
<span class="min-w-0">
|
|
<span class="block font-semibold truncate">{candidate.name}</span>
|
|
<span class="block text-xs opacity-75 truncate">
|
|
{candidate.isLibrary ? "Whole library" : `In ${candidate.libraryName}`}
|
|
</span>
|
|
</span>
|
|
<span class="text-xs font-semibold uppercase tracking-wide shrink-0">
|
|
{excludedIds.has(candidate.id) ? "Hidden" : "Visible"}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-3">
|
|
Changes apply to listings loaded from now on; reopen a page to see them take effect.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Crossfade -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Crossfade</h2>
|
|
<p class="text-sm text-gray-400 mt-1">Fade between tracks for seamless transitions</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
|
{settings.crossfadeDuration.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="12"
|
|
step="0.5"
|
|
value={settings.crossfadeDuration}
|
|
oninput={handleCrossfadeInput}
|
|
onchange={handleCrossfadeChange}
|
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
|
/>
|
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
<span>0s (Off)</span>
|
|
<span>12s (Max)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gapless Playback -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Gapless Playback</h2>
|
|
<p class="text-sm text-gray-400 mt-1">Eliminate silence between tracks in albums</p>
|
|
</div>
|
|
<button
|
|
onclick={handleGaplessToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.gaplessPlayback
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle gapless playback"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.gaplessPlayback
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume Normalization -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Volume Normalization</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically adjust volume levels for consistent playback
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleNormalizeToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.normalizeVolume
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle volume normalization"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.normalizeVolume
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if settings.normalizeVolume}
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Target Volume Level</p>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("loud")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'loud'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Loud</div>
|
|
<div class="text-xs opacity-75">-11 LUFS</div>
|
|
</button>
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("normal")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'normal'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Normal</div>
|
|
<div class="text-xs opacity-75">-14 LUFS</div>
|
|
</button>
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("quiet")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'quiet'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Quiet</div>
|
|
<div class="text-xs opacity-75">-23 LUFS</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Equalizer (UR-027) -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Equalizer</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Shape the sound with presets or custom bands (Linux)
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleEqToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.equalizerEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle equalizer"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.equalizerEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if settings.equalizerEnabled}
|
|
<!-- Preset chips -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Presets</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each eqPresets as [name, gains] (name)}
|
|
<button
|
|
onclick={() => handleEqPreset(gains)}
|
|
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset === name
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
{EQ_PRESET_LABELS[name]}
|
|
</button>
|
|
{/each}
|
|
{#if activeEqPreset === null}
|
|
<span
|
|
class="px-3 py-1.5 rounded-full text-sm bg-[var(--color-jellyfin)] text-white"
|
|
>
|
|
Custom
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Band sliders -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-4">Bands (dB)</p>
|
|
<div class="flex justify-between gap-1 sm:gap-2">
|
|
{#each EQ_BAND_LABELS as label, i (label)}
|
|
<div class="flex flex-col items-center gap-2 flex-1 min-w-0">
|
|
<span class="text-xs text-gray-400 tabular-nums">
|
|
{eqBands[i] > 0 ? "+" : ""}{eqBands[i]}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={EQ_GAIN_MIN}
|
|
max={EQ_GAIN_MAX}
|
|
step="1"
|
|
value={eqBands[i]}
|
|
oninput={(e) => handleEqBandInput(i, e)}
|
|
onchange={(e) => handleEqBandChange(i, e)}
|
|
class="eq-slider"
|
|
aria-label="{label} Hz gain"
|
|
/>
|
|
<span class="text-xs text-gray-500">{label}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Video Playback Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
|
|
|
|
<!-- Auto-play Next Episode -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Auto-play Next Episode</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically start the next episode when one finishes
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleAutoPlayToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {videoSettings.autoPlayNextEpisode
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle auto-play next episode"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {videoSettings.autoPlayNextEpisode
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if videoSettings.autoPlayNextEpisode}
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-300">Countdown Duration</p>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Time before next episode starts automatically
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
|
{videoSettings.autoPlayCountdownSeconds}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="5"
|
|
max="30"
|
|
step="5"
|
|
value={videoSettings.autoPlayCountdownSeconds}
|
|
oninput={handleCountdownInput}
|
|
onchange={handleCountdownChange}
|
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
|
/>
|
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
<span>5s</span>
|
|
<span>30s</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Episode Limit -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<div class="mb-4">
|
|
<p class="text-sm font-medium text-gray-300">Episode Limit</p>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Stop auto-playing after this many consecutive episodes
|
|
</p>
|
|
</div>
|
|
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
|
{#each episodeLimitOptions as option}
|
|
<button
|
|
onclick={() => handleEpisodeLimitChange(option.value)}
|
|
class="py-3 px-3 rounded-lg transition-all text-sm
|
|
{videoSettings.autoPlayMaxEpisodes === option.value
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">{option.label}</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Streaming quality: the bandwidth ceiling every video stream is
|
|
opened against. The steps and their labels come from Rust.
|
|
TRACES: UR-074 | DR-162 -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
|
<h3 class="text-xl font-semibold text-white">Streaming Quality</h3>
|
|
<p class="text-sm text-gray-400 mt-1 mb-4">
|
|
Limit how much bandwidth video streams may use. Lower settings ask the server to
|
|
transcode before sending, which saves data on metered or slow connections at the cost of
|
|
picture quality. You can also change this for a single video from the player's quality
|
|
menu.
|
|
</p>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{#each streamingQualities as [quality, label, detail]}
|
|
<button
|
|
onclick={() => handleStreamingQualityChange(quality)}
|
|
class="py-3 px-3 rounded-lg transition-all text-left
|
|
{videoSettings.streamingQuality === quality
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={videoSettings.streamingQuality === quality}
|
|
>
|
|
<div class="font-semibold text-sm">{label}</div>
|
|
<div class="text-xs opacity-75 mt-0.5">{detail}</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-3">
|
|
Applies to videos started from now on; a video already playing keeps the quality it
|
|
started at.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Native video (experimental). Only rendered where the platform's Rust
|
|
backend actually has a native video surface (Android). -->
|
|
{#if supportsNativeVideo}
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="pr-4">
|
|
<h3 class="text-xl font-semibold text-white">
|
|
Native Video
|
|
<span
|
|
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
|
|
>
|
|
Experimental
|
|
</span>
|
|
</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Decode video with the device's hardware decoder instead of the built-in web
|
|
player, for better performance and battery life, and so picture-in-picture shows
|
|
the video rather than the app. On by default. Turn it off to fall back to the
|
|
built-in web player if a video misbehaves.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleNativeVideoToggle}
|
|
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle native video"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Search Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-white mb-1">Result Group Order</h3>
|
|
<p class="text-sm text-gray-400 mb-4">
|
|
Drag or use the arrows to choose the order search result groups appear in. Empty groups
|
|
are hidden automatically.
|
|
</p>
|
|
<SearchGroupOrderList />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending server updates. Lives here as well as behind the offline
|
|
banner's badge, because a row that keeps failing is still queued once
|
|
the server is reachable again — when no banner is on screen.
|
|
TRACES: UR-025 | DR-132 -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Waiting to sync</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<PendingSyncList />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Cache Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
|
|
<!-- Cache Usage -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="text-lg font-semibold text-white">Cache Usage</h3>
|
|
{#if cacheLoading}
|
|
<span class="text-sm text-gray-400">Loading...</span>
|
|
{:else if cacheStats}
|
|
<span class="text-sm text-gray-300">
|
|
{formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0
|
|
? "Unlimited"
|
|
: formatBytes(cacheStats.limitBytes)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if cacheStats && cacheStats.limitBytes > 0}
|
|
<!-- Progress bar -->
|
|
<div class="w-full bg-gray-700 rounded-full h-3 mb-2">
|
|
<div
|
|
class="h-3 rounded-full transition-all duration-300 {getCacheUsagePercent() > 90
|
|
? 'bg-red-500'
|
|
: getCacheUsagePercent() > 70
|
|
? 'bg-yellow-500'
|
|
: 'bg-[var(--color-jellyfin)]'}"
|
|
style="width: {getCacheUsagePercent()}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="text-sm text-gray-400">
|
|
{#if cacheStats}
|
|
{cacheStats.itemCount} images cached
|
|
{:else}
|
|
Thumbnails and artwork are cached locally for faster loading
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Cache Limit -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<h3 class="text-lg font-semibold text-white mb-3">Storage Limit</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
{#each cacheLimitOptions as option}
|
|
<button
|
|
onclick={() => handleCacheLimitChange(option.bytes)}
|
|
class="py-2 px-3 rounded-lg transition-all text-sm {isCurrentLimit(option.bytes)
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">{option.label}</div>
|
|
{#if option.default}
|
|
<div class="text-xs opacity-75">Default</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear Cache -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">Clear Image Cache</h3>
|
|
<p class="text-sm text-gray-400">Remove all cached thumbnails and artwork</p>
|
|
</div>
|
|
<button
|
|
onclick={handleClearCache}
|
|
disabled={clearingCache || (cacheStats?.itemCount ?? 0) === 0}
|
|
class="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{clearingCache ? "Clearing..." : "Clear Cache"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Download Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Downloads</h2>
|
|
|
|
<!-- Storage Limit -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="mb-4">
|
|
<h3 class="text-xl font-semibold text-white">Storage Limit</h3>
|
|
<p class="text-sm text-gray-400 mt-1">Maximum storage for offline downloads</p>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">5 GB</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-[var(--color-jellyfin)] text-white"
|
|
>
|
|
<div class="font-semibold">10 GB</div>
|
|
<div class="text-xs opacity-75">Default</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">20 GB</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">Unlimited</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Smart Caching -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Smart Caching</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically download albums you're listening to
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleSmartCachingToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle smart caching"
|
|
aria-pressed={cacheConfig.albumAffinityEnabled}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.albumAffinityEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Queue Pre-caching -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Download the next {cacheConfig.queuePrecacheCount} tracks in the queue automatically
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleQueuePrecacheToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle queue pre-caching"
|
|
aria-pressed={cacheConfig.queuePrecacheEnabled}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.queuePrecacheEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WiFi Only -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">WiFi Only</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
{#if networkDetectionSupported}
|
|
Hold downloads unless on an unmetered network. Cellular and metered hotspots are
|
|
excluded; WiFi and Ethernet are allowed.
|
|
{:else}
|
|
Only available on Android — this device has no metered connection to detect.
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleWifiOnlyToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'} {networkDetectionSupported ? '' : 'opacity-50 cursor-not-allowed'}"
|
|
aria-label="Toggle WiFi only downloads"
|
|
aria-pressed={cacheConfig.wifiOnly}
|
|
disabled={!networkDetectionSupported}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.wifiOnly
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Diagnostics.
|
|
Logs live on disk, redacted, and export as one file for a bug
|
|
report. Nothing is transmitted; the user attaches it themselves.
|
|
TRACES: UR-078 | DR-218 -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Diagnostics</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white mb-1">Detail level</h3>
|
|
<p class="text-sm text-gray-400 mb-3">
|
|
Higher detail helps diagnose a problem but writes more to disk. The setting survives a
|
|
restart, so you can turn it up and then reproduce the bug.
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each LOG_LEVELS as level (level)}
|
|
<button
|
|
class="px-3 py-1.5 rounded-lg text-sm font-medium capitalize {diagnosticsInfo?.level ===
|
|
level
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300'}"
|
|
onclick={() => handleLogLevelChange(level)}
|
|
>
|
|
{level}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">Export diagnostics</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Bundles the logs and your app/OS versions into one file to attach to a bug report.
|
|
Access tokens and passwords are removed; nothing is uploaded anywhere.
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
|
|
onclick={handleExportDiagnostics}
|
|
disabled={exporting}
|
|
>
|
|
{exporting ? "Exporting…" : "Export"}
|
|
</button>
|
|
</div>
|
|
|
|
{#if exportedBundle}
|
|
<div class="mt-3 border border-gray-700 rounded-lg p-3 space-y-2">
|
|
<p class="text-sm text-green-400">
|
|
Saved {formatBytes(exportedBundle.sizeBytes)} from {exportedBundle.fileCount} log file{exportedBundle.fileCount ===
|
|
1
|
|
? ""
|
|
: "s"}.
|
|
</p>
|
|
<p class="text-xs text-gray-400 break-all font-mono">{exportedBundle.path}</p>
|
|
{#if canInstallUpdates}
|
|
<button
|
|
class="text-sm text-[var(--color-jellyfin)] underline"
|
|
onclick={() => revealItemInDir(exportedBundle!.path)}
|
|
>
|
|
Show in folder
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else if exportError}
|
|
<p class="mt-3 text-sm text-yellow-400">Couldn't export: {exportError}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if diagnosticsInfo}
|
|
<div class="text-xs text-gray-500 space-y-1">
|
|
<p class="font-mono break-all">{diagnosticsInfo.logDir}</p>
|
|
<p>{formatBytes(diagnosticsInfo.totalSizeBytes)} of logs held</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Updates.
|
|
Desktop installs in place; Android can only be pointed at the
|
|
releases page, because an app may not replace its own APK. The
|
|
decision lives in $lib/utils/updateCheck.ts, not in this markup.
|
|
TRACES: UR-077 | DR-217 -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Updates</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">
|
|
{canInstallUpdates ? "Check for updates" : "Get the latest version"}
|
|
</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
{#if canInstallUpdates}
|
|
Downloads are verified against JellyTau's signing key before anything is
|
|
installed.
|
|
{:else}
|
|
Android installs are handled by the system installer — this opens the releases
|
|
page.
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
|
|
onclick={handleCheckForUpdates}
|
|
disabled={updateState === "checking" || updateState === "installing"}
|
|
>
|
|
{#if updateState === "checking"}
|
|
Checking…
|
|
{:else if canInstallUpdates}
|
|
Check now
|
|
{:else}
|
|
Open releases
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if updateState === "current"}
|
|
<p class="text-sm text-green-400">You're on the latest version.</p>
|
|
{:else if updateState === "failed"}
|
|
<p class="text-sm text-yellow-400">
|
|
Couldn't reach the update server. This is safe to ignore — JellyTau keeps working.
|
|
</p>
|
|
{:else if updateState === "installing"}
|
|
<div>
|
|
<p class="text-sm text-gray-300 mb-2">
|
|
Downloading… {Math.round(updateProgress * 100)}%
|
|
</p>
|
|
<div class="h-2 bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-[var(--color-jellyfin)] transition-all"
|
|
style="width: {updateProgress * 100}%"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-2">JellyTau will restart when this finishes.</p>
|
|
</div>
|
|
{:else if updateState === "available" && updateAction.kind === "install"}
|
|
<div class="border border-gray-700 rounded-lg p-4 space-y-3">
|
|
<p class="text-white font-medium">Version {updateAction.version} is available</p>
|
|
{#if updateAction.notes}
|
|
<pre
|
|
class="text-sm text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">{updateAction.notes}</pre>
|
|
{/if}
|
|
<button
|
|
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium"
|
|
onclick={handleInstallUpdate}
|
|
>
|
|
Install and restart
|
|
</button>
|
|
</div>
|
|
{:else if updateState === "available" && updateAction.kind === "open-releases"}
|
|
<button
|
|
class="text-sm text-[var(--color-jellyfin)] underline"
|
|
onclick={() =>
|
|
openUrl(updateAction.kind === "open-releases" ? updateAction.url : RELEASES_URL)}
|
|
>
|
|
Version {updateAction.version} is available — open the releases page
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Box -->
|
|
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
|
<div class="flex gap-3">
|
|
<svg
|
|
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<div class="text-sm text-blue-300">
|
|
<p class="font-semibold mb-1">About these settings:</p>
|
|
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
|
<li>
|
|
<strong>Crossfade</strong> smoothly blends the end of one track with the beginning of
|
|
the next
|
|
</li>
|
|
<li>
|
|
<strong>Gapless</strong> removes silence between tracks for continuous album playback
|
|
</li>
|
|
<li>
|
|
<strong>Normalization</strong> evens out loudness between tracks in real time, toward
|
|
your selected level
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* Vertical EQ band sliders. `appearance: slider-vertical` is deprecated;
|
|
use writing-mode which is the supported path in modern WebKit/Chromium. */
|
|
.eq-slider {
|
|
writing-mode: vertical-lr;
|
|
direction: rtl;
|
|
width: 8px;
|
|
height: 96px;
|
|
accent-color: var(--color-jellyfin);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|