Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/main/actions/SyncPublisherStatsAction.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describeStatsFailure, type PublisherStatsProblem } from '../services/publisherStatsProblem.js';
import axios from 'axios';
import { BaseAction } from './BaseAction.js';
import { AppDataSource } from '../data-source.js';
Expand Down Expand Up @@ -34,8 +35,19 @@ export interface SyncPublisherStatsOutput {
* will recommend deleting the whole library the first time it runs.
*/
countingSince: string | null;
/**
* Why there are no numbers, when there are none for a reason.
*
* This used to answer every failure with zeros, which is the one answer that
* cannot be told from an empty publisher: a server that was off, a wrong
* address, a token that no longer matched and a container older than the
* counter all drew "0 published, 0 B" and the page's own "could not reach"
* state never appeared. Null when the publisher answered.
*/
problem: PublisherStatsProblem | null;
}


/**
* Bring the publisher's view counts back onto the rows.
*
Expand All @@ -60,6 +72,7 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
updated: 0,
totals: { clips: 0, bytes: 0, views: 0 },
countingSince: null,
problem: null,
};

const baseUrl = publisherBaseUrl();
Expand Down Expand Up @@ -92,7 +105,7 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
error instanceof Error ? error.message : String(error),
);
}
return empty;
return { ...empty, problem: describeStatsFailure(status, baseUrl) };
}

const stats = new Map((payload.clips ?? []).map((stat) => [stat.filename, stat]));
Expand All @@ -108,6 +121,7 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
updated: 0,
totals: payload.totals ?? empty.totals,
countingSince: payload.countingSince ?? null,
problem: null,
};
}

Expand All @@ -134,6 +148,7 @@ export class SyncPublisherStatsAction extends BaseAction<void, SyncPublisherStat
updated,
totals: payload.totals ?? empty.totals,
countingSince: payload.countingSince ?? null,
problem: null,
};
}
}
31 changes: 31 additions & 0 deletions src/main/services/publisherStatsProblem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** Why the publisher gave no numbers. */
export interface PublisherStatsProblem {
kind: 'unreachable' | 'unauthorized' | 'outdated';
/** A sentence for the page, in the reader's words rather than the HTTP client's. */
message: string;
}

/** What a failed stats request means, for somebody who has not read the code. */
export function describeStatsFailure(status: number | undefined, baseUrl: string): PublisherStatsProblem {
if (status === 404) {
return {
kind: 'outdated',
message:
'Your publisher is older than the view counter, so it has nothing to report yet. ' +
'Update its container and this page fills in.',
};
}
if (status === 401 || status === 403 || status === 503) {
return {
kind: 'unauthorized',
message:
status === 503
? 'The publisher has no PUBLISH_TOKEN set, so it refuses to say what it holds.'
: 'The publisher refused the token. It has to match PUBLISH_TOKEN on the server.',
};
}
return {
kind: 'unreachable',
message: `Nothing answered at ${baseUrl}. The server may be off, or the address may be wrong.`,
};
}
10 changes: 7 additions & 3 deletions src/renderer/src/components/Editor/TimelineClipMarks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,18 @@ const laneHeightPercent = 100 / MAX_BAND_LANES;

<template>
<!--
Along the bottom edge, above the duration label's line but below the
controls, and never taking a pointer: the block underneath is draggable on
Along the bottom edge, on a ground of its own, and never taking a pointer.
It was a six pixel strip split three ways straight over the thumbnail
strip, so one mark was a two pixel line that disappeared into whatever
frame happened to be under it; twelve pixels on the card colour make each
row four and legible over any picture. The duration label sits above it.
Never taking a pointer: the block underneath is draggable on
its whole surface and a band that swallowed a mousedown would make part of
the clip unmovable.
-->
<div
v-if="bands.length"
class="absolute left-0 right-0 bottom-0 h-1.5 pointer-events-none"
class="absolute left-0 right-0 bottom-0 h-3 bg-card/80 pointer-events-none"
aria-hidden="true"
>
<div
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/components/Editor/TimelineTrack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ onBeforeUnmount(stopDrag);
</span>

<span
class="absolute left-2 bottom-1 font-mono text-[10.5px] text-muted-600 pointer-events-none"
class="absolute left-2 font-mono text-[10.5px] text-muted-600 pointer-events-none"
:class="clip.goodBits?.length ? 'bottom-3.5' : 'bottom-1'"
>
{{ formatTime(clip.duration) }}
</span>
Expand All @@ -201,7 +202,8 @@ onBeforeUnmount(stopDrag);
<Icon
v-if="clip.muted"
icon="material-symbols:volume-off"
class="absolute right-2 bottom-1 size-3.5 block text-muted-500 pointer-events-none"
class="absolute right-2 size-3.5 block text-muted-500 pointer-events-none"
:class="clip.goodBits?.length ? 'bottom-3.5' : 'bottom-1'"
/>

<button
Expand Down
54 changes: 39 additions & 15 deletions src/renderer/src/components/Publish/PublisherClipRow.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { MOTION } from '@renderer/components/Base/geometry';
import { Icon } from '@iconify/vue';
import { FOCUS_RING, MOTION } from '@renderer/components/Base/geometry';
import { thumbnailUrl } from '@renderer/utils/mediaUrl';
import { useFormat } from '@renderer/composables/ui/useFormat';
import { formatRelativeTime } from '@renderer/helpers/dateFormat';
Expand All @@ -17,9 +18,15 @@ import type { Clip } from '@renderer/types/clip';
interface Props {
clip: Clip;
selected?: boolean;
/**
* Off means the row cannot be picked at all. "Most opened" is there to be
* read, not cleaned up: offering to unpublish the links people actually watch
* put a checkbox on the one list where pressing it is the wrong idea.
*/
selectable?: boolean;
}

const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), { selectable: true });
const emit = defineEmits<{ (e: 'toggle'): void }>();

const { formatBytes } = useFormat();
Expand All @@ -43,27 +50,44 @@ const watched = computed(() => {
return {
label: String(views),
hint: props.clip.publisherLastViewedAt
? `last ${formatRelativeTime(props.clip.publisherLastViewedAt)}`
? `last opened ${formatRelativeTime(props.clip.publisherLastViewedAt)}`
: '',
};
});
</script>

<template>
<label
<!--
A label when it can be picked, so pressing anywhere on the row picks it,
and a plain row when it cannot. The checkbox is the app's own drawing over
a visually hidden input: a native one is painted by Chromium in white on
the dark palette and matched nothing else on the screen.
-->
<component
:is="selectable ? 'label' : 'div'"
:class="[
'grid grid-cols-[1.25rem_4rem_1fr_auto_auto] items-center gap-3 px-2 py-2 rounded-md cursor-pointer',
'grid items-center gap-3 px-2 py-2 rounded-md',
selectable ? 'grid-cols-[1.25rem_4rem_1fr_auto_auto] cursor-pointer' : 'grid-cols-[4rem_1fr_auto_auto]',
MOTION,
selected ? 'bg-accent-sunk' : 'hover:bg-muted-50',
selected ? 'bg-accent-sunk' : selectable ? 'hover:bg-muted-50' : '',
]"
>
<input
type="checkbox"
class="size-4"
:checked="selected"
:aria-label="`Select ${title}`"
@change="emit('toggle')"
/>
<span v-if="selectable" class="relative inline-flex size-5 items-center justify-center">
<input
type="checkbox"
:class="['peer absolute inset-0 opacity-0 cursor-pointer', FOCUS_RING]"
:checked="selected"
:aria-label="`Select ${title}`"
@change="emit('toggle')"
/>
<Icon
:icon="selected ? 'material-symbols:check-box' : 'material-symbols:check-box-outline-blank'"
:class="[
'size-5 block pointer-events-none rounded-sm peer-focus-visible:focus-ring',
selected ? 'text-accent' : 'text-muted-400',
]"
/>
</span>

<img
:src="thumbnailUrl(clip.id)"
Expand All @@ -80,9 +104,9 @@ const watched = computed(() => {
{{ formatBytes(clip.sizeBytes) }}
</div>

<div class="w-28 text-right">
<div class="w-36 text-right">
<div class="font-mono text-sm tabular-nums text-foreground">{{ watched.label }}</div>
<div class="text-[11px] text-muted-400">{{ watched.hint }}</div>
</div>
</label>
</component>
</template>
35 changes: 21 additions & 14 deletions src/renderer/src/components/Settings/PublisherCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,27 @@ async function testPublisher(): Promise<void> {
<p v-else-if="publisherState === 'unreachable'" class="!mt-2 !text-danger-ink">
No answer from that address.
</p>
</div>

<!--
A different question from compressing a trim, which is why it is a
different switch and now on a different page: what goes to a public link
is a copy, so shrinking it costs nothing on disk. Hidden when there is no
publisher, since a setting for a feature you do not have is noise.
-->
<SettingToggle
v-if="settings.publisherBaseUrl"
label="Compress clips when publishing"
description="The file on disk is untouched; only the copy behind the public link is re-encoded, so it downloads in a fifth of the time. Off uploads the recording as it is."
:model-value="settings.compressPublished !== false"
@update:model-value="saveCompressPublished($event)"
/>
<!--
Inside the card, so the hairline under the card is under this too. It
sat outside it, which put the Claude heading straight under its
description with no rule between two unrelated things.
-->
<div class="mt-3">
<!--
A different question from compressing a trim, which is why it is a
different switch and now on a different page: what goes to a public link
is a copy, so shrinking it costs nothing on disk. Hidden when there is no
publisher, since a setting for a feature you do not have is noise.
-->
<SettingToggle
v-if="settings.publisherBaseUrl"
label="Compress clips when publishing"
description="The file on disk is untouched; only the copy behind the public link is re-encoded, so it downloads in a fifth of the time. Off uploads the recording as it is."
:model-value="settings.compressPublished !== false"
@update:model-value="saveCompressPublished($event)"
/>
</div>
</div>
</div>
</template>
14 changes: 12 additions & 2 deletions src/renderer/src/components/Settings/RecordingSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,18 @@ function openGuide(): void {
<p class="text-sm text-muted-500 max-w-[62ch]">
OBS is still recording at
<span class="text-foreground">{{ qualityInObs ?? 'something else' }}</span
>. This lands the next time the setup is written, which cannot happen while OBS is
open.
>.
<!--
Two sentences, because the way to finish it depends on whether OBS
is running. It used to say "cannot happen while OBS is open" when
OBS was closed and the button beside it would write it right now.
-->
<template v-if="status?.running">
GoodBit cannot change it while OBS is open, so close OBS and write it then.
</template>
<template v-else>
Write it to OBS and the next recording uses it.
</template>
</p>
</div>
<BaseButton
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/src/components/Storage/BurstCluster.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<script setup lang="ts">
import BasePanel from '@renderer/components/Base/BasePanel.vue';
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
import { ICON_BOX, SECTION_HEADER } from '@renderer/components/Base/geometry';
import { SECTION_HEADER } from '@renderer/components/Base/geometry';
import BaseButton from '@renderer/components/Base/BaseButton.vue';
import StorageClipTile from './StorageClipTile.vue';
import { useFormat } from '@renderer/composables/ui/useFormat';
Expand Down Expand Up @@ -83,11 +82,11 @@ const publishedDoomed = computed(() => doomed.value.filter((clip) => clip.publis

<BaseButton
tone="danger"
icon="material-symbols:delete-outline"
class="ml-auto"
:disabled="deleting || doomed.length === 0"
@click="emit('delete', doomed)"
>
<Icon icon="material-symbols:delete-outline" :class="ICON_BOX" />
Delete the other {{ doomed.length }}
</BaseButton>
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/src/components/Storage/BurstSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,15 @@ const emit = defineEmits<{
</BaseEmptyState>

<template v-else>
<ReclaimSummary :bytes="data.reclaimableBytes" :clips="data.totalClips" />
<!--
The clips that would go, not every clip in a burst: keeping one of each
is the point, so "1.2 MB across 3 clips" about three saves of which two
go was a count of the wrong thing.
-->
<ReclaimSummary
:bytes="data.reclaimableBytes"
:clips="data.totalClips - data.clusters.length"
/>

<div class="mt-6 space-y-4">
<BurstClusterCard
Expand Down
24 changes: 17 additions & 7 deletions src/renderer/src/components/Storage/GraveyardSection.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { ICON_BOX, SECTION_HEADER } from '@renderer/components/Base/geometry';
import { computed } from 'vue';
import { SECTION_HEADER } from '@renderer/components/Base/geometry';
import BaseButton from '@renderer/components/Base/BaseButton.vue';
import BaseEmptyState from '@renderer/components/Base/BaseEmptyState.vue';
import StorageClipTile from './StorageClipTile.vue';
Expand Down Expand Up @@ -39,6 +39,14 @@ const emit = defineEmits<{

const { formatBytes } = useFormat();

/** What the ticked clips weigh, for the bar that deletes them. */
const selectedBytes = computed(() =>
(props.data?.groups ?? [])
.flatMap((group) => group.clips)
.filter((clip) => props.selected.has(clip.id))
.reduce((sum, clip) => sum + (clip.sizeBytes ?? 0), 0),
);

function groupSelected(clips: Clip[]): boolean {
return clips.length > 0 && clips.every((clip) => props.selected.has(clip.id));
}
Expand Down Expand Up @@ -107,25 +115,27 @@ function groupSelected(clips: Clip[]): boolean {
</div>

<!--
The delete, written here beside the sentence explaining what it
destroys rather than reached for from a shared danger token. There is
deliberately no danger button in `geometry.ts` for exactly that reason:
making one easy to reach for is how it ends up on the wrong button.
The delete, outlined in danger and never filled, and it says how much
it frees as well as how many: the whole screen is about space, and the
count alone made somebody add up sizes by eye.
-->
<div
v-if="selected.size"
class="sticky bottom-4 mt-6 flex items-center gap-3 rounded-lg border border-line-strong bg-card px-4 py-3 shadow-pop"
>
<span class="text-sm text-foreground">
{{ selected.size }} selected
<span class="font-mono text-xs tabular-nums text-muted-500">
· {{ formatBytes(selectedBytes) }}
</span>
</span>
<BaseButton
tone="danger"
icon="material-symbols:delete-outline"
class="ml-auto"
:disabled="deleting"
@click="emit('delete')"
>
<Icon icon="material-symbols:delete-outline" :class="ICON_BOX" />
Delete selected
</BaseButton>
</div>
Expand Down
Loading
Loading