new player

This commit is contained in:
Scott Chacon 2023-03-15 11:09:32 +01:00
parent a8a2e2b0a9
commit c024510a69
2 changed files with 333 additions and 273 deletions

View File

@ -32,96 +32,98 @@
</script>
<div class="flex h-full w-full flex-col">
<nav
class="project-top-bar flex flex-none select-none items-center justify-between space-x-3 border-b border-zinc-700 p-[6px] px-8 text-zinc-300"
>
<div class="flex flex-row items-center space-x-2">
<form action="/projects/{$project?.id}/search" method="GET">
<label
for="default-search"
class="sr-only mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label
>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="mr-2 h-5 w-5" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
><path
d="M8 12a4 4 0 110-8 4 4 0 010 8zm9.707 4.293l-4.82-4.82A5.968 5.968 0 0014 8 6 6 0 002 8a6 6 0 006 6 5.968 5.968 0 003.473-1.113l4.82 4.82a.997.997 0 001.414 0 .999.999 0 000-1.414z"
fill="#5C5F62"
/></svg
>
</div>
<div class="flex w-48 max-w-lg rounded-md shadow-sm">
<input
type="text"
name="search"
id="search"
placeholder="search"
autocomplete="off"
aria-label="Search input"
class="block w-full min-w-0 flex-1 rounded border border-zinc-700 bg-zinc-800 p-[3px] px-2 pl-10 text-zinc-200 placeholder:text-zinc-500 sm:text-sm sm:leading-6"
style=""
/>
<div
class="absolute right-1 top-1 inline-flex items-center rounded border border-zinc-700/20 bg-zinc-700/50 px-1 py-[2px] text-gray-400 shadow sm:text-sm"
>
&#8984;K
</div>
</div>
</div>
</form>
<a href="/projects/{$project?.id}/player" class="text-zinc-400 hover:text-zinc-200">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z"
/>
</svg>
</a>
</div>
<ul>
<li>
<a href="/projects/{$project?.id}/settings" class="text-zinc-400 hover:text-zinc-300">
<div class="rounded-md p-1 hover:bg-zinc-700 hover:bg-zinc-700 hover:text-zinc-200">
<div class="h-6 w-6 ">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
{#if selection != 'player'}
<nav
class="project-top-bar flex flex-none select-none items-center justify-between space-x-3 border-b border-zinc-700 p-[6px] px-8 text-zinc-300"
>
<div class="flex flex-row items-center space-x-2">
<form action="/projects/{$project?.id}/search" method="GET">
<label
for="default-search"
class="sr-only mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label
>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="mr-2 h-5 w-5" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
><path
d="M8 12a4 4 0 110-8 4 4 0 010 8zm9.707 4.293l-4.82-4.82A5.968 5.968 0 0014 8 6 6 0 002 8a6 6 0 006 6 5.968 5.968 0 003.473-1.113l4.82 4.82a.997.997 0 001.414 0 .999.999 0 000-1.414z"
fill="#5C5F62"
/></svg
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class="flex w-48 max-w-lg rounded-md shadow-sm">
<input
type="text"
name="search"
id="search"
placeholder="search"
autocomplete="off"
aria-label="Search input"
class="block w-full min-w-0 flex-1 rounded border border-zinc-700 bg-zinc-800 p-[3px] px-2 pl-10 text-zinc-200 placeholder:text-zinc-500 sm:text-sm sm:leading-6"
style=""
/>
<div
class="absolute right-1 top-1 inline-flex items-center rounded border border-zinc-700/20 bg-zinc-700/50 px-1 py-[2px] text-gray-400 shadow sm:text-sm"
>
&#8984;K
</div>
</div>
</div>
</form>
<a href="/projects/{$project?.id}/player" class="text-zinc-400 hover:text-zinc-200">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z"
/>
</svg>
</a>
</li>
</ul>
</nav>
</div>
<ul>
<li>
<a href="/projects/{$project?.id}/settings" class="text-zinc-400 hover:text-zinc-300">
<div class="rounded-md p-1 hover:bg-zinc-700 hover:bg-zinc-700 hover:text-zinc-200">
<div class="h-6 w-6 ">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
</div>
</a>
</li>
</ul>
</nav>
{/if}
<div class="project-container h-100 flex-auto overflow-y-auto">
<slot />

View File

@ -12,150 +12,205 @@
const { sessions } = data;
let currentTimestamp = new Date().getTime();
let currentPlayerValue = 0;
let currentDay = dateToYmd(new Date());
$: minVisibleTimestamp =
$sessions.length > 0
? Math.max(
Math.min(currentTimestamp - 12 * 60 * 60 * 1000, $sessions[0].meta.startTimestampMs),
$sessions.at(-1)!.meta.startTimestampMs
)
: 0;
$: sessionDays = $sessions.reduce((group, session) => {
const day = dateToYmd(new Date(session.meta.startTimestampMs));
group[day] = group[day] ?? [];
group[day].push(session);
// sort by startTimestampMs
group[day].sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs);
return group;
}, {});
let maxVisibleTimestamp = new Date().getTime();
onMount(() => {
const inverval = setInterval(() => {
maxVisibleTimestamp = new Date().getTime();
}, 1000);
return () => clearInterval(inverval);
$: currentSessions = $sessions.filter((session) => {
let sessionDay = dateToYmd(new Date(session.meta.startTimestampMs));
return sessionDay === currentDay;
});
$: visibleSessions = $sessions.filter(
(session) =>
session.meta.startTimestampMs >= minVisibleTimestamp ||
session.meta.lastTimestampMs >= minVisibleTimestamp
);
$: earliestVisibleSession = visibleSessions.at(-1)!;
let deltasBySessionId: Record<string, Promise<Record<string, Delta[]>>> = {};
$: visibleSessions
.filter((s) => deltasBySessionId[s.id] === undefined)
let currentDeltas: Record<string, Promise<Record<string, Delta[]>>> = {};
$: currentSessions
.filter((s) => currentDeltas[s.id] === undefined)
.forEach((s) => {
deltasBySessionId[s.id] = listDeltas({
currentDeltas[s.id] = listDeltas({
projectId: data.projectId,
sessionId: s.id
});
});
let docsBySessionId: Record<string, Promise<Record<string, string>>> = {};
$: if (earliestVisibleSession && docsBySessionId[earliestVisibleSession.id] === undefined) {
docsBySessionId[earliestVisibleSession.id] = listFiles({
type VideoFileEdit = {
filepath: string;
delta: Delta;
};
type VideoChapter = {
title: string;
session: string;
files: Record<string, number>;
edits: VideoFileEdit[];
editCount: number;
firstDeltaTimestampMs: number;
lastDeltaTimestampMs: number;
totalDurationMs: number;
};
type DayVideo = {
chapters: VideoChapter[];
editCount: number;
totalDurationMs: number;
firstDeltaTimestampMs: number;
lastDeltaTimestampMs: number;
};
let dayPlaylist: Record<string, DayVideo> = {};
let sessionFiles: Record<string, Record<string, string>> = {};
let sessionChapters: Record<string, VideoChapter> = {};
$: currentSessions.forEach((s) => listSession(s.id));
function listSession(sid: string) {
console.log('session', sid);
currentDeltas[sid].then((deltas) => {
if (sessionChapters[sid] === undefined) {
sessionChapters[sid] = {
title: sid,
session: sid,
files: {},
edits: [],
editCount: 0,
firstDeltaTimestampMs: 9999999999999,
lastDeltaTimestampMs: 0,
totalDurationMs: 0
};
}
sessionChapters[sid].edits = [];
Object.entries(deltas).forEach(([filepath, deltas]) => {
deltas.forEach((delta) => {
sessionChapters[sid].edits.push({
filepath,
delta
});
});
if (sessionFiles[sid] === undefined) sessionFiles[sid] = {};
sessionFiles[sid][filepath] = '';
sessionChapters[sid].editCount = sessionChapters[sid].edits.length;
sessionChapters[sid].files[filepath] = deltas.length;
sessionChapters[sid].firstDeltaTimestampMs = min(
deltas.at(0)!.timestampMs,
sessionChapters[sid].firstDeltaTimestampMs
);
sessionChapters[sid].lastDeltaTimestampMs = max(
deltas.at(-1)!.timestampMs,
sessionChapters[sid].lastDeltaTimestampMs
);
sessionChapters[sid].totalDurationMs =
sessionChapters[sid].lastDeltaTimestampMs - sessionChapters[sid].firstDeltaTimestampMs;
});
console.log('sessionChapters', sessionChapters[sid]);
// get the session chapters that are in the current day
let dayChapters = Object.entries(sessionChapters)
.filter(([sid, chapter]) => {
let chapterDay = dateToYmd(new Date(chapter.firstDeltaTimestampMs));
return chapterDay === currentDay;
})
.map(([, chapter]) => chapter)
.sort((a, b) => a.firstDeltaTimestampMs - b.firstDeltaTimestampMs);
dayPlaylist[currentDay] = {
chapters: dayChapters,
editCount: dayChapters.reduce((acc, chapter) => acc + chapter.editCount, 0),
totalDurationMs: Object.values(dayChapters).reduce(
(acc, chapter) => acc + chapter.totalDurationMs,
0
),
firstDeltaTimestampMs: Object.values(dayChapters).reduce(
(acc, chapter) => min(acc, chapter.firstDeltaTimestampMs),
9999999999999
),
lastDeltaTimestampMs: Object.values(dayChapters).reduce(
(acc, chapter) => max(acc, chapter.lastDeltaTimestampMs),
0
)
};
console.log('dayPlaylist', dayPlaylist);
});
listFiles({
projectId: data.projectId,
sessionId: earliestVisibleSession.id
sessionId: sid
}).then((files) => {
Object.entries(sessionFiles[sid]).forEach(([filepath, _]) => {
if (files[filepath] === undefined) {
console.log('file not found', filepath);
} else {
sessionFiles[sid][filepath] = files[filepath];
console.log('file found', sid, filepath, files[filepath]);
}
});
});
}
$: visibleDeltasByFilepath = Promise.all(
visibleSessions.map((s) => deltasBySessionId[s.id])
).then((deltasBySessionId) =>
Object.values(deltasBySessionId).reduce((acc, deltasByFilepath) => {
Object.entries(deltasByFilepath).forEach(([filepath, deltas]) => {
deltas = deltas.filter((delta) => delta.timestampMs <= currentTimestamp);
if (acc[filepath] === undefined) acc[filepath] = [];
acc[filepath].push(...deltas);
});
return acc;
}, {} as Record<string, Delta[]>)
);
function max<T>(a: T, b: T): T {
return a > b ? a : b;
}
let frame: {
function min<T>(a: T, b: T): T {
return a < b ? a : b;
}
function dateToYmd(date: Date): string {
const year = date.getFullYear();
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const day = ('0' + date.getDate()).slice(-2);
return `${year}-${month}-${day}`;
}
function ymdToDate(dateString: string): Date {
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
}
function selectDay(day: string) {
return () => {
console.log('select day', day);
currentDay = day;
currentPlayerValue = 0;
};
}
type EditFrame = {
sessionId: string;
timestampMs: number;
filepath: string;
doc: string;
deltas: Delta[];
} | null = null;
$: visibleDeltasByFilepath
.then(
(visibleDeltasByFilepath) =>
Object.entries(visibleDeltasByFilepath)
.filter(([_, deltas]) => deltas.length > 0)
.map(([filepath, deltas]) => [filepath, deltas.at(-1)!.timestampMs] as [string, number])
.sort((a, b) => b[1] - a[1])
.at(0)?.[0] ?? null
)
.then(async (visibleFilepath) => {
if (earliestVisibleSession && visibleFilepath !== null) {
frame = {
deltas:
(await visibleDeltasByFilepath.then((deltasByFilepath) =>
deltasByFilepath[visibleFilepath].sort((a, b) => a.timestampMs - b.timestampMs)
)) || [],
doc:
(await docsBySessionId[earliestVisibleSession.id].then(
(docsByFilepath) => docsByFilepath[visibleFilepath]
)) || '',
filepath: visibleFilepath
ops: Delta[];
delta: Delta;
};
let currentEdit: EditFrame | null = null;
$: if (currentPlayerValue > 0) {
let totalEdits = 0;
currentEdit = null;
dayPlaylist[currentDay].chapters.forEach((chapter) => {
console.log(totalEdits);
if (currentEdit == null && currentPlayerValue < totalEdits + chapter.editCount) {
let thisEdit = chapter.edits[currentPlayerValue - totalEdits];
currentEdit = {
sessionId: chapter.session,
timestampMs: thisEdit.delta.timestampMs,
filepath: thisEdit.filepath,
doc: sessionFiles[chapter.session][thisEdit.filepath],
ops: [],
delta: thisEdit.delta
};
}
totalEdits += chapter.editCount;
});
// player
let interval: ReturnType<typeof setInterval> | undefined;
let direction: -1 | 1 = 1;
let speed = 1;
let oneSecond = 1000;
const stop = () => {
clearInterval(interval);
interval = undefined;
speed = 1;
};
const play = () => start({ direction, speed });
const start = (params: { direction: 1 | -1; speed: number }) => {
if (interval) clearInterval(interval);
interval = setInterval(() => {
currentTimestamp += oneSecond * params.direction;
}, oneSecond / params.speed);
};
const speedUp = () => {
speed = speed * 2;
start({ direction, speed });
};
$: visibleRanges = visibleSessions
.map(
({ meta }) =>
[
Math.max(meta.startTimestampMs, minVisibleTimestamp),
Math.min(meta.lastTimestampMs, maxVisibleTimestamp)
] as [number, number]
)
.sort((a, b) => a[0] - b[0])
.reduce((timeline, range) => {
const [from, to] = range;
const last = timeline.at(-1);
if (last) timeline.push([last[1], from, false]);
timeline.push([from, to, true]);
return timeline;
}, [] as [number, number, boolean][]);
const rangeWidth = (range: [number, number]) =>
(100 * (range[1] - range[0])) / (maxVisibleTimestamp - minVisibleTimestamp) + '%';
const timestampToOffset = (timestamp: number) =>
((timestamp - minVisibleTimestamp) / (maxVisibleTimestamp - minVisibleTimestamp)) * 100 + '%';
const offsetToTimestamp = (offset: number) =>
offset * (maxVisibleTimestamp - minVisibleTimestamp) + minVisibleTimestamp;
let timeline: HTMLElement;
const onSelectTimestamp = (e: MouseEvent) => {
const { left, width } = timeline.getBoundingClientRect();
const clickOffset = e.clientX;
const clickPos = Math.min(Math.max((clickOffset - left) / width, 0), 1) || 0;
currentTimestamp = offsetToTimestamp(clickPos);
};
}
</script>
{#if $sessions.length === 0}
@ -166,74 +221,77 @@
</div>
</div>
{:else}
<div class="flex h-full w-full flex-col">
{#if frame !== null}
<header class="shadow-md">
<h2 class="px-4 py-2 text-xl text-zinc-300">{frame.filepath}</h2>
</header>
<div
class="project-container flex-auto overflow-y-auto overflow-x-hidden"
style="height: calc(100vh - 270px)"
>
<CodeViewer filepath={frame.filepath} doc={frame.doc} deltas={frame.deltas} />
</div>
{/if}
<div id="timeline" class="relative w-full px-4 pb-4" bind:this={timeline}>
<div
id="cursor"
use:slider
on:drag={({ detail: v }) => (currentTimestamp = offsetToTimestamp(v))}
class="absolute flex h-12 w-4 cursor-pointer items-center justify-around transition hover:scale-150"
style:left="calc({timestampToOffset(currentTimestamp)} - 0.5rem)"
>
<div class="h-5 w-0.5 rounded-sm bg-white" />
</div>
<div class="mb-4 flex w-full items-center justify-between">
<div id="from">
{new Date(minVisibleTimestamp).toLocaleString()}
<div class="flex flex-row h-full w-full border">
<div class="w-64 flex-shrink-0 border">
<div>Playlist</div>
{#each Object.entries(sessionDays) as [day, sessions]}
<div class="flex flex-col border" on:click={selectDay(day)}>
<div class="text-gray-500">{day}</div>
{sessions.length}
</div>
<div id="to">
{new Date(maxVisibleTimestamp).toLocaleString()}
{/each}
</div>
<div class="flex-grow">
<div class="flex flex-col h-full w-full border">
<div class="flex-grow border overflow-auto">
{ymdToDate(currentDay)}
{#if dayPlaylist[currentDay] !== undefined}
<div>{dayPlaylist[currentDay].chapters.length} chapters</div>
<div>{dayPlaylist[currentDay].editCount} edits</div>
<div>{Math.round(dayPlaylist[currentDay].totalDurationMs / 1000 / 60)} min</div>
{#if currentEdit !== null}
<div>{currentEdit.sessionId}</div>
<div>{currentEdit.filepath}</div>
<div>{currentEdit.delta.timestampMs}</div>
<div>{new Date(currentEdit.delta.timestampMs)}</div>
<div>{currentEdit.delta.operations}</div>
<CodeViewer
doc={currentEdit.doc}
deltas={[currentEdit.delta]}
filepath={currentEdit.filepath}
/>
{/if}
{:else}
loading...
{/if}
<div>{currentPlayerValue}</div>
</div>
</div>
<div class="w-full px-10">
<div id="ranges" class="flex w-full items-center gap-1" on:mousedown={onSelectTimestamp}>
<div
class="h-2 rounded-sm"
style:background-color="inherit"
style:width={rangeWidth([minVisibleTimestamp, visibleRanges[0][0]])}
/>
{#each visibleRanges as [from, to, filled]}
<div
class="h-2 rounded-sm"
style:background-color={filled ? '#D9D9D9' : 'inherit'}
style:width={rangeWidth([from, to])}
/>
{/each}
<div
class="h-2 rounded-sm"
style:background-color="inherit"
style:width={rangeWidth([
visibleRanges[visibleRanges.length - 1][1],
maxVisibleTimestamp
])}
/>
<div class="border">Meta Data</div>
<div class="flex flex-row border">
<div>play</div>
<div class="w-full">
{#if dayPlaylist[currentDay] !== undefined}
<input
type="range"
class="w-full bg-white"
max={dayPlaylist[currentDay].editCount}
step="1"
bind:value={currentPlayerValue}
/>
{/if}
</div>
<div>2x</div>
</div>
</div>
</div>
<div class="mx-auto flex items-center gap-2">
{#if interval}
<button on:click={stop}><IconPlayerPauseFilled class="h-6 w-6" /></button>
{:else}
<button on:click={play}><IconPlayerPlayFilled class="h-6 w-6" /></button>
{/if}
<button on:click={speedUp}>{speed}x</button>
<div class="w-64 border">
<div>Sessions</div>
<div class="flex flex-col">
{#each sessionDays[currentDay] as session}
<div class="border overflow-auto">
{new Date(session.meta.startTimestampMs).toLocaleTimeString()}
to
{new Date(session.meta.lastTimestampMs).toLocaleTimeString()}
{#if sessionChapters[session.id] !== undefined}
<div>{Math.round(sessionChapters[session.id].totalDurationMs / 1000 / 60)} min</div>
{#each Object.entries(sessionChapters[session.id].files) as [filepath, count]}
<div>{filepath}</div>
<div>{count}</div>
{/each}
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}