Repository: PostHog/posthog
Pull request: feat: Revamped Recordings list
## Problem

Needs https://github.com/PostHog/posthog/pull/12207 (some of the logic breaks the old view so we may as well wait till this is merged than fix the old view)

The new recordings playlist uses a LemonTable which is overkill given the data source and we want some specific styles that don't work well with it. In addition we aren't indicating if recordings have been watched so now we add a little blob indicator.

Closes https://github.com/PostHog/posthog/issues/12209

## Changes
* Use custom list instead of table due to different rendering needs (single column table doesn't allow flexing nicely)
* Styling of text more inline with other lists
* Duration position and font changed to make it clearer if the length is > 1 hour
* Dot indicator if the recording has not previously been viewed.
* Modified logic to load whatever recording is selected in the hash params. If none selected it selects the first in the list **but softly** meaning on reload it will load the top recording. Only once one is actively clicked will it be permanently "selected"
* This also fixes the issue that when paginating, the selected recording disappears.

|Before|After|
|----|----|
|<img width="559" alt="Screenshot 2022-10-12 at 13 55 26" src="https://user-images.githubusercontent.com/2536520/195336483-783aa12b-ab9b-476a-ba22-8345d14176b5.png">|<img width="582" alt="Screenshot 2022-10-12 at 13 55 54" src="https://user-images.githubusercontent.com/2536520/195336491-4d59d581-d480-4bd9-bcd6-19ffbb28eab0.png">|
|<img width="1422" alt="Screenshot 2022-10-12 at 14 01 50" src="https://user-images.githubusercontent.com/2536520/195337565-2c36367e-11de-4464-8c51-dfd70335ac53.png">|<img width="1426" alt="Screenshot 2022-10-12 at 14 01 28" src="https://user-images.githubusercontent.com/2536520/195337551-6625e8db-c4d0-4dfd-9ee1-3a3fb7939249.png">|
|Too much margin <img width="450" alt="Screenshot 2022-10-12 at 14 03 25" src="https://user-images.githubusercontent.com/2536520/195338043-787dd589-8d45-47ca-b066-ec5877ccb625.png">|Less margin <img width="431" alt="Screenshot 2022-10-12 at 14 03 40" src="https://user-images.githubusercontent.com/2536520/195338053-bbaeb53e-14e0-49b4-b3d0-42579b4163d1.png">|
|No pagination controls <img width="449" alt="Screenshot 2022-10-12 at 14 03 56" src="https://user-images.githubusercontent.com/2536520/195338057-74ae982f-dbba-4975-97df-cdf9cbce4c97.png">|Pagination controls <img width="431" alt="Screenshot 2022-10-12 at 14 04 19" src="https://user-images.githubusercontent.com/2536520/195338059-cf773ccc-3170-4e6b-bbb2-7a096a8e0da5.png">|
|Hard to click bottom pagination <img width="443" alt="Screenshot 2022-10-12 at 14 04 01" src="https://user-images.githubusercontent.com/2536520/195338058-c100fde2-6ad8-412c-9a8d-2b4d22eee23d.png">|Easy to click bottom pagination <img width="439" alt="Screenshot 2022-10-12 at 14 04 23" src="https://user-images.githubusercontent.com/2536520/195338061-d56025e3-1c86-4be8-8d39-65e7e8900bbe.png">|



👉 *Stay up-to-date with [PostHog coding conventions](https://posthog.com/docs/contribute/coding-conventions) for a smoother review.*

## How did you test this code?

Lots of manual testing

Problem statement:
Display if a recording is watched on the Recordings page
## Is your feature request related to a problem?

I see that the recordings page is updated with a new UI/UX. The last version had a way to identify if the user had already watched the recording or not (the play icon was blue when a recording was not watched, and gray when watched).

## Describe the solution you'd like

I'd like a way for me to understand if a recording is watched or not. Adding an icon or tag to identify if a recording is watched or not works fine. 

## Describe alternatives you've considered

I tried using the filters, but that's too confusing IMO. I should be able to see if a recording is watched or not right away.

## Additional context



#### *Thank you* for your feature request – we love each and every one!

Hints from issue discussion:
@eraldoforgoli Thanks for the suggestion. This was already in the works but I bumped it up. Should have an indicator as well as some other improvements coming in the next days 💪

Commit message: Fixed view on small screens

Patch to review:
diff --git a/frontend/src/lib/components/LemonTable/LemonTable.scss b/frontend/src/lib/components/LemonTable/LemonTable.scss
index fda2a12bddaff..6bf3fe9e888f5 100644
--- a/frontend/src/lib/components/LemonTable/LemonTable.scss
+++ b/frontend/src/lib/components/LemonTable/LemonTable.scss
@@ -66,31 +66,6 @@
             &.LemonTable__row--grouping {
                 --row-base-height: 2.5rem; // Make group headers smaller for better hierarchy
             }
-            &.LemonTable__loader {
-                transition: height 200ms ease, top 200ms ease;
-                z-index: 10;
-                position: absolute;
-                left: 0;
-                bottom: -1px;
-                width: 100%;
-                height: 0;
-                background: var(--primary-bg-active);
-                border: none !important;
-                overflow: hidden;
-                &::after {
-                    content: '';
-                    position: absolute;
-                    left: 0;
-                    top: 0;
-                    width: 50%;
-                    height: 100%;
-                    animation: loading-bar 1.5s linear infinite;
-                    background: var(--primary);
-                }
-                .LemonTable--loading & {
-                    height: 0.25rem;
-                }
-            }
         }
     }
     > tbody {
@@ -175,19 +150,3 @@
     align-items: center;
     line-height: 1.5;
 }
-
-@keyframes loading-bar {
-    0% {
-        left: 0;
-        width: 33.333%;
-        transform: translateX(-100%);
-    }
-    50% {
-        width: 50%;
-    }
-    100% {
-        left: 100%;
-        width: 33.333%;
-        transform: translateX(100%);
-    }
-}
diff --git a/frontend/src/lib/components/LemonTable/LemonTable.tsx b/frontend/src/lib/components/LemonTable/LemonTable.tsx
index bee7924a70d34..759e45fcf835d 100644
--- a/frontend/src/lib/components/LemonTable/LemonTable.tsx
+++ b/frontend/src/lib/components/LemonTable/LemonTable.tsx
@@ -10,6 +10,7 @@ import { ExpandableConfig, LemonTableColumn, LemonTableColumnGroup, LemonTableCo
 import { PaginationAuto, PaginationControl, PaginationManual, usePagination } from '../PaginationControl'
 import { useScrollable } from 'lib/hooks/useScrollable'
 import { LemonSkeleton } from '../LemonSkeleton'
+import { LemonTableLoader } from './LemonTableLoader'
 
 /**
  * Determine the column's key, using `dataIndex` as fallback.
@@ -304,8 +305,8 @@ export function LemonTable<T extends Record<string, any>>({
                                             </th>
                                         ))
                                     )}
+                                    <LemonTableLoader loading={loading} />
                                 </tr>
-                                <tr className="LemonTable__loader" />
                             </thead>
                         )}
                         <tbody>
diff --git a/frontend/src/lib/components/LemonTable/LemonTableLoader.scss b/frontend/src/lib/components/LemonTable/LemonTableLoader.scss
new file mode 100644
index 0000000000000..15091eef3c23b
--- /dev/null
+++ b/frontend/src/lib/components/LemonTable/LemonTableLoader.scss
@@ -0,0 +1,41 @@
+.LemonTableLoader {
+    transition: height 200ms ease, top 200ms ease;
+    z-index: 10;
+    position: absolute;
+    left: 0;
+    bottom: -1px;
+    width: 100%;
+    height: 0;
+    background: var(--primary-bg-active);
+    border: none !important;
+    overflow: hidden;
+    &::after {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 50%;
+        height: 100%;
+        animation: loading-bar 1.5s linear infinite;
+        background: var(--primary);
+    }
+    &.LemonTableLoader--loading {
+        height: 0.25rem;
+    }
+}
+
+@keyframes loading-bar {
+    0% {
+        left: 0;
+        width: 33.333%;
+        transform: translateX(-100%);
+    }
+    50% {
+        width: 50%;
+    }
+    100% {
+        left: 100%;
+        width: 33.333%;
+        transform: translateX(100%);
+    }
+}
diff --git a/frontend/src/lib/components/LemonTable/LemonTableLoader.tsx b/frontend/src/lib/components/LemonTable/LemonTableLoader.tsx
new file mode 100644
index 0000000000000..0832667b5e9b9
--- /dev/null
+++ b/frontend/src/lib/components/LemonTable/LemonTableLoader.tsx
@@ -0,0 +1,7 @@
+import clsx from 'clsx'
+import React from 'react'
+import './LemonTableLoader.scss'
+
+export function LemonTableLoader({ loading = false }: { loading?: boolean }): JSX.Element {
+    return <div className={clsx('LemonTableLoader', loading && 'LemonTableLoader--loading')} />
+}
diff --git a/frontend/src/scenes/project-homepage/RecentRecordings.tsx b/frontend/src/scenes/project-homepage/RecentRecordings.tsx
index 3f1bba897568e..3186deee236c7 100644
--- a/frontend/src/scenes/project-homepage/RecentRecordings.tsx
+++ b/frontend/src/scenes/project-homepage/RecentRecordings.tsx
@@ -15,14 +15,14 @@ import { humanFriendlyDuration } from 'lib/utils'
 import { IconPlayCircle } from 'lib/components/icons'
 import { SessionPlayerModal } from 'scenes/session-recordings/player/modal/SessionPlayerModal'
 import { teamLogic } from 'scenes/teamLogic'
+import { sessionPlayerModalLogic } from 'scenes/session-recordings/player/modal/sessionPlayerModalLogic'
 
 interface RecordingRowProps {
     recording: SessionRecordingType
 }
 
 function RecordingRow({ recording }: RecordingRowProps): JSX.Element {
-    const sessionRecordingsListLogicInstance = sessionRecordingsListLogic({ key: 'projectHomepage' })
-    const { openSessionPlayer } = useActions(sessionRecordingsListLogicInstance)
+    const { openSessionPlayer } = useActions(sessionPlayerModalLogic)
     const { reportRecordingOpenedFromRecentRecordingList } = useActions(eventUsageLogic)
 
     return (
@@ -30,6 +30,10 @@ function RecordingRow({ recording }: RecordingRowProps): JSX.Element {
             fullWidth
             onClick={() => {
                 openSessionPlayer(recording)
+                openSessionPlayer({
+                    id: recording.id,
+                    matching_events: recording.matching_events,
+                })
                 reportRecordingOpenedFromRecentRecordingList()
             }}
         >
diff --git a/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.scss b/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.scss
index b072f6e306e97..14589e4ff18d2 100644
--- a/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.scss
+++ b/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.scss
@@ -2,11 +2,12 @@
 
 .SessionRecordingsPlaylist {
     display: flex;
-    flex-direction: column;
+    flex-direction: column-reverse;
     gap: 1rem;
 
     @include screen($md) {
         flex-direction: row;
+        justify-content: flex-start;
     }
 
     .SessionRecordingsPlaylist__left-column {
diff --git a/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.tsx
index b162e98582123..e4bb3ef619610 100644
--- a/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.tsx
+++ b/frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.tsx
@@ -1,104 +1,176 @@
 import React, { useRef } from 'react'
 import { useActions, useValues } from 'kea'
-import { colonDelimitedDuration } from '~/lib/utils'
+import { colonDelimitedDuration, range } from '~/lib/utils'
 import { SessionRecordingType } from '~/types'
-import { getRecordingListLimit, PLAYLIST_LIMIT, sessionRecordingsListLogic } from './sessionRecordingsListLogic'
+import { PLAYLIST_LIMIT, sessionRecordingsListLogic } from './sessionRecordingsListLogic'
 import { asDisplay } from 'scenes/persons/PersonHeader'
 import './SessionRecordingsPlaylist.scss'
-import { LemonTable, LemonTableColumns } from 'lib/components/LemonTable'
 import { TZLabel } from 'lib/components/TimezoneAware'
 import { SessionRecordingPlayer } from './player/SessionRecordingPlayer'
 import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage'
 import { LemonButton } from '@posthog/lemon-ui'
-import { IconChevronLeft, IconChevronRight } from 'lib/components/icons'
+import { IconChevronLeft, IconChevronRight, IconSchedule } from 'lib/components/icons'
 import { SessionRecordingsFilters } from './filters/SessionRecordingsFilters'
+import clsx from 'clsx'
+import { Tooltip } from 'lib/components/Tooltip'
+import { LemonSkeleton } from 'lib/components/LemonSkeleton'
+import { LemonTableLoader } from 'lib/components/LemonTable/LemonTableLoader'
 
 interface SessionRecordingsTableProps {
     personUUID?: string
     isPersonPage?: boolean
 }
 
+const DurationDisplay = ({ duration }: { duration: number }): JSX.Element => {
+    const formattedDuration = colonDelimitedDuration(duration)
+    const parts = formattedDuration.split(':')
+
+    return (
+        <span className="flex items-center gap-1">
+            <IconSchedule className="text-lg" />
+            <span>
+                <span className={parts[0] === '00' ? 'opacity-50' : ''}>{parts[0]}:</span>
+                <span className={parts[0] === '00' && parts[1] === '00' ? 'opacity-50' : ''}>{parts[1]}:</span>
+                {parts[2]}
+            </span>
+        </span>
+    )
+}
+
 export function SessionRecordingsPlaylist({ personUUID }: SessionRecordingsTableProps): JSX.Element {
-    const sessionRecordingsListLogicInstance = sessionRecordingsListLogic({ personUUID, isPlaylist: true })
+    const logic = sessionRecordingsListLogic({ personUUID })
     const { sessionRecordings, sessionRecordingsResponseLoading, hasNext, hasPrev, activeSessionRecording, offset } =
-        useValues(sessionRecordingsListLogicInstance)
-    const { openSessionPlayer, loadNext, loadPrev } = useActions(sessionRecordingsListLogicInstance)
+        useValues(logic)
+    const { setSelectedRecordingId, loadNext, loadPrev } = useActions(logic)
     const playlistRef = useRef<HTMLDivElement>(null)
 
-    const columns: LemonTableColumns<SessionRecordingType> = [
-        {
-            title: 'Recordings',
-            render: function RenderPlayButton(_: any, sessionRecording: SessionRecordingType) {
-                return (
-                    <div>
-                        {asDisplay(sessionRecording.person, 25)}
-                        <div>
-                            <span>
-                                <TZLabel
-                                    time={sessionRecording.start_time}
-                                    formatDate="MMMM DD, YYYY"
-                                    formatTime="h:mm A"
-                                />
-                                {` · `}
-                                {colonDelimitedDuration(sessionRecording.recording_duration)}
-                            </span>
-                        </div>
-                    </div>
-                )
-            },
-        },
-    ]
+    const onRecordingClick = (recording: SessionRecordingType): void => {
+        setSelectedRecordingId(recording.id)
+        window.scrollTo({
+            left: 0,
+            top: playlistRef?.current?.offsetTop ? playlistRef.current.offsetTop - 8 : 0,
+            behavior: 'smooth',
+        })
+    }
+
+    const nextLength = offset + (sessionRecordingsResponseLoading ? PLAYLIST_LIMIT : sessionRecordings.length)
+
+    const paginationControls = nextLength ? (
+        <div className="flex items-center gap-1">
+            <span>{`${offset + 1} - ${nextLength}`}</span>
+            <LemonButton
+                icon={<IconChevronLeft />}
+                status="stealth"
+                size="small"
+                disabled={!hasPrev}
+                onClick={() => {
+                    loadPrev()
+                    window.scrollTo(0, 0)
+                }}
+            />
+            <LemonButton
+                icon={<IconChevronRight />}
+                status="stealth"
+                disabled={!hasNext}
+                size="small"
+                onClick={() => {
+                    loadNext()
+                    window.scrollTo(0, 0)
+                }}
+            />
+        </div>
+    ) : null
+
     return (
         <div ref={playlistRef} className="SessionRecordingsPlaylist" data-attr="session-recordings-playlist">
             <div className="SessionRecordingsPlaylist__left-column space-y-4">
                 <SessionRecordingsFilters personUUID={personUUID} />
-                <LemonTable
-                    dataSource={sessionRecordings}
-                    columns={columns}
-                    loading={sessionRecordingsResponseLoading}
-                    onRow={(sessionRecording) => ({
-                        onClick: (e) => {
-                            // Lets the link to the person open the person's page and not the session recording
-                            if (!(e.target as HTMLElement).closest('a')) {
-                                openSessionPlayer({ id: sessionRecording.id })
-                                window.scrollTo({
-                                    left: 0,
-                                    top: playlistRef?.current?.offsetTop ? playlistRef.current.offsetTop - 8 : 0,
-                                    behavior: 'smooth',
-                                })
-                            }
-                        },
-                    })}
-                    rowStatus={(recording) => (activeSessionRecording?.id === recording.id ? 'highlighted' : null)}
-                    rowClassName="cursor-pointer"
-                    data-attr="session-recording-table"
-                    data-tooltip="session-recording-table"
-                    emptyState="No matching recordings found"
-                    loadingSkeletonRows={PLAYLIST_LIMIT}
-                />
-                <div className="flex justify-end items-center my-2">
-                    <span>{`${offset + 1} - ${
-                        offset +
-                        (sessionRecordingsResponseLoading ? getRecordingListLimit(true) : sessionRecordings.length)
-                    }`}</span>
+
+                <div className="w-full overflow-hidden border rounded">
+                    <div className="relative flex justify-between items-center bg-mid py-3 px-4 border-b">
+                        <span className="font-bold uppercase text-xs my-1 tracking-wide">Recent Recordings</span>
+                        {paginationControls}
+
+                        <LemonTableLoader loading={sessionRecordingsResponseLoading} />
+                    </div>
+
+                    {!sessionRecordings.length ? (
+                        sessionRecordingsResponseLoading ? (
+                            <>
+                                {range(PLAYLIST_LIMIT).map((i) => (
+                                    <div key={i} className="p-4 space-y-2 border-b">
+                                        <LemonSkeleton className="w-1/2" />
+                                        <LemonSkeleton className="w-1/3" />
+                                    </div>
+                                ))}
+                            </>
+                        ) : (
+                            <p className="text-muted-alt m-4">No matching recordings found</p>
+                        )
+                    ) : (
+                        <ul className={clsx(sessionRecordingsResponseLoading ? 'opacity-50' : '')}>
+                            {sessionRecordings.map((rec, i) => (
+                                <li
+                                    key={rec.id}
+                                    className={clsx(
+                                        'p-2 px-4 cursor-pointer',
+                                        activeSessionRecording?.id === rec.id
+                                            ? 'bg-primary-highlight font-semibold'
+                                            : '',
+                                        i !== 0 && 'border-t'
+                                    )}
+                                    onClick={() => onRecordingClick(rec)}
+                                >
+                                    <div className="flex justify-between items-center">
+                                        <div className="truncate font-medium text-primary">
+                                            {asDisplay(rec.person, 25)}
+                                        </div>
+                                        {!rec.viewed && (
+                                            <Tooltip title={'Indicates the recording has not been watched yet'}>
+                                                <div
+                                                    className="w-2 h-2 rounded bg-primary-light"
+                                                    aria-label="unwatched-recording-label"
+                                                />
+                                            </Tooltip>
+                                        )}
+                                    </div>
+
+                                    <div className="flex justify-between">
+                                        <TZLabel time={rec.start_time} formatDate="MMMM DD, YYYY" formatTime="h:mm A" />
+                                        <DurationDisplay duration={rec.recording_duration} />
+                                    </div>
+                                </li>
+                            ))}
+                        </ul>
+                    )}
+                </div>
+
+                <div className="flex justify-between items-center">
                     <LemonButton
                         icon={<IconChevronLeft />}
-                        status="stealth"
+                        type="secondary"
                         disabled={!hasPrev}
                         onClick={() => {
                             loadPrev()
                             window.scrollTo(0, 0)
                         }}
-                    />
+                    >
+                        Previous
+                    </LemonButton>
+
+                    <span>{`${offset + 1} - ${nextLength}`}</span>
+
                     <LemonButton
                         icon={<IconChevronRight />}
-                        status="stealth"
+                        type="secondary"
                         disabled={!hasNext}
                         onClick={() => {
                             loadNext()
                             window.scrollTo(0, 0)
                         }}
-                    />
+                    >
+                        Next
+                    </LemonButton>
                 </div>
             </div>
             <div className="SessionRecordingsPlaylist__right-column">
@@ -106,18 +178,20 @@ export function SessionRecordingsPlaylist({ personUUID }: SessionRecordingsTable
                     <div className="border rounded h-full">
                         <SessionRecordingPlayer
                             playerKey="playlist"
-                            sessionRecordingId={activeSessionRecording.id}
+                            sessionRecordingId={activeSessionRecording?.id}
                             matching={activeSessionRecording?.matching_events}
                             recordingStartTime={activeSessionRecording ? activeSessionRecording.start_time : undefined}
                         />
                     </div>
                 ) : (
-                    <EmptyMessage
-                        title="No recording selected"
-                        description="Please select a recording from the list on the left"
-                        buttonText="Learn more about recordings"
-                        buttonTo="https://posthog.com/docs/user-guides/recordings"
-                    />
+                    <div className="mt-20">
+                        <EmptyMessage
+                            title="No recording selected"
+                            description="Please select a recording from the list on the left"
+                            buttonText="Learn more about recordings"
+                            buttonTo="https://posthog.com/docs/user-guides/recordings"
+                        />
+                    </div>
                 )}
             </div>
         </div>
diff --git a/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx b/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx
index e40915d478472..88fcf231a827a 100644
--- a/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx
+++ b/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx
@@ -61,7 +61,7 @@ export function SessionRecordingsFilters({
                         />
                     </div>
                     {!isPersonPage && (
-                        <div className="mt-8 space-y-2">
+                        <div className="mt-4 space-y-2">
                             <LemonLabel info="Show recordings by persons who match the set criteria">
                                 Filter by persons and cohorts
                             </LemonLabel>
diff --git a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.scss b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.scss
index 04d79c6a66245..b5cba67011341 100644
--- a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.scss
+++ b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.scss
@@ -10,7 +10,6 @@
         position: relative;
         z-index: 0;
         background-color: var(--bg-light);
-        flex: 1;
         min-width: 1px;
         height: 500px;
         min-height: 500px;
diff --git a/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.test.ts b/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.test.ts
index b2647abadd969..f81346d58a63e 100644
--- a/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.test.ts
+++ b/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.test.ts
@@ -31,7 +31,7 @@ describe('sessionPlayerModalLogic', () => {
             expectLogic(logic, () => logic.actions.openSessionPlayer({ id: 'abc' }))
                 .toDispatchActions(['getSessionRecordingsSuccess'])
                 .toMatchValues({
-                    partialSessionRecording: { id: 'abc' },
+                    selectedSessionRecording: { id: 'abc' },
                     activeSessionRecording: listOfSessionRecordings[0],
                 })
             expect(router.values.hashParams).toHaveProperty('sessionRecordingId', 'abc')
diff --git a/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.test.ts b/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.test.ts
index 7ba5aefd5c4be..2d8f8e50eca2f 100644
--- a/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.test.ts
+++ b/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.test.ts
@@ -1,5 +1,6 @@
 import {
     sessionRecordingsListLogic,
+    PLAYLIST_LIMIT,
     DEFAULT_ENTITY_FILTERS,
     DEFAULT_DURATION_FILTER,
 } from './sessionRecordingsListLogic'
@@ -36,11 +37,11 @@ describe('sessionRecordingsListLogic', () => {
                                 results: ["List of specific user's recordings from server"],
                             },
                         ]
-                    } else if (searchParams.get('offset') === '50') {
+                    } else if (searchParams.get('offset') === `${PLAYLIST_LIMIT}`) {
                         return [
                             200,
                             {
-                                results: ['List of recordings offset by 50'],
+                                results: [`List of recordings offset by ${PLAYLIST_LIMIT}`],
                             },
                         ]
                     } else if (
@@ -89,33 +90,53 @@ describe('sessionRecordingsListLogic', () => {
 
         describe('activeSessionRecording', () => {
             it('starts as null', () => {
-                expectLogic(logic).toMatchValues({ activeSessionRecording: null })
+                expectLogic(logic).toMatchValues({ activeSessionRecording: undefined })
             })
-            it('is set by openSessionPlayer and cleared by closeSessionPlayer', async () => {
-                expectLogic(logic, () => logic.actions.openSessionPlayer({ id: 'abc' }))
+            it('is set by setSessionRecordingId', async () => {
+                expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc'))
                     .toDispatchActions(['getSessionRecordingsSuccess'])
                     .toMatchValues({
-                        partialSessionRecording: { id: 'abc' },
+                        selectedRecordingId: 'abc',
                         activeSessionRecording: listOfSessionRecordings[0],
                     })
                 expect(router.values.hashParams).toHaveProperty('sessionRecordingId', 'abc')
+            })
 
-                expectLogic(logic, () => logic.actions.closeSessionPlayer()).toMatchValues({
-                    activeSessionRecording: null,
-                })
-                expect(router.values.hashParams).not.toHaveProperty('sessionRecordingId')
+            it('is partial if sessionRecordingId not in list', async () => {
+                expectLogic(logic, () => logic.actions.setSelectedRecordingId('not-in-list'))
+                    .toDispatchActions(['getSessionRecordingsSuccess'])
+                    .toMatchValues({
+                        selectedRecordingId: 'not-in-list',
+                        activeSessionRecording: { id: 'not-in-list' },
+                    })
+                expect(router.values.hashParams).toHaveProperty('sessionRecordingId', 'not-in-list')
             })
+
             it('is read from the URL on the session recording page', async () => {
                 router.actions.push('/recordings', {}, { sessionRecordingId: 'abc' })
                 expect(router.values.hashParams).toHaveProperty('sessionRecordingId', 'abc')
 
                 await expectLogic(logic)
-                    .toDispatchActionsInAnyOrder(['openSessionPlayer', 'getSessionRecordingsSuccess'])
+                    .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'getSessionRecordingsSuccess'])
                     .toMatchValues({
-                        partialSessionRecording: { id: 'abc' },
+                        selectedRecordingId: 'abc',
                         activeSessionRecording: listOfSessionRecordings[0],
                     })
             })
+
+            it('mounts and loads the recording when a recording is opened', () => {
+                expectLogic(logic, async () => await logic.actions.setSelectedRecordingId('abcd'))
+                    .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' }))
+                    .toDispatchActions(['loadEntireRecording'])
+            })
+
+            it('returns the first session recording if none selected', () => {
+                expectLogic(logic).toDispatchActions(['getSessionRecordingsSuccess']).toMatchValues({
+                    selectedRecordingId: undefined,
+                    activeSessionRecording: listOfSessionRecordings[0],
+                })
+                expect(router.values.hashParams).not.toHaveProperty('sessionRecordingId', 'not-in-list')
+            })
         })
 
         describe('entityFilters', () => {
@@ -144,10 +165,10 @@ describe('sessionRecordingsListLogic', () => {
                 await expectLogic(logic, () => {
                     logic.actions.loadNext()
                 })
-                    .toMatchValues({ offset: 50 })
+                    .toMatchValues({ offset: PLAYLIST_LIMIT })
                     .toDispatchActions(['loadNext', 'getSessionRecordingsSuccess'])
-                    .toMatchValues({ sessionRecordings: ['List of recordings offset by 50'] })
-                expect(router.values.searchParams.filters).toHaveProperty('offset', 50)
+                    .toMatchValues({ sessionRecordings: [`List of recordings offset by ${PLAYLIST_LIMIT}`] })
+                expect(router.values.searchParams.filters).toHaveProperty('offset', PLAYLIST_LIMIT)
 
                 await expectLogic(logic, () => {
                     logic.actions.loadPrev()
@@ -208,7 +229,7 @@ describe('sessionRecordingsListLogic', () => {
         })
 
         describe('sessionRecording.viewed', () => {
-            it('changes when openSessionRecording is called', () => {
+            it('changes when setSelectedRecordingId is called', () => {
                 expectLogic(logic, () => {
                     logic.actions.getSessionRecordingsSuccess({
                         results: [
@@ -235,7 +256,7 @@ describe('sessionRecordingsListLogic', () => {
                 })
 
                 expectLogic(logic, () => {
-                    logic.actions.openSessionPlayer({ id: 'abc' })
+                    logic.actions.setSelectedRecordingId('abc')
                 }).toMatchValues({
                     sessionRecordings: [
                         {
@@ -298,17 +319,6 @@ describe('sessionRecordingsListLogic', () => {
                 })
         })
     })
-    describe('playlist mode', () => {
-        beforeEach(() => {
-            logic = sessionRecordingsListLogic({ isPlaylist: true })
-            logic.mount()
-        })
-        it('mounts and loads the recording when a recording is opened', () => {
-            expectLogic(logic, async () => await logic.actions.openSessionPlayer({ id: 'abcd' }))
-                .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' }))
-                .toDispatchActions(['loadEntireRecording'])
-        })
-    })
     describe('person specific logic', () => {
         beforeEach(() => {
             logic = sessionRecordingsListLogic({ personUUID: 'cool_user_99' })
@@ -325,7 +335,7 @@ describe('sessionRecordingsListLogic', () => {
             router.actions.push('/person/123', {}, { sessionRecordingId: 'abc' })
             expect(router.values.hashParams).toHaveProperty('sessionRecordingId', 'abc')
 
-            await expectLogic(logic).toDispatchActions([logic.actionCreators.openSessionPlayer({ id: 'abc' })])
+            await expectLogic(logic).toDispatchActions([logic.actionCreators.setSelectedRecordingId('abc')])
         })
     })
 })
diff --git a/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.ts b/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.ts
index 4786e61fd4d4c..db3826e77b285 100644
--- a/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.ts
+++ b/frontend/src/scenes/session-recordings/sessionRecordingsListLogic.ts
@@ -28,11 +28,8 @@ interface HashParams {
     sessionRecordingId?: SessionRecordingId
 }
 
-const TABLE_LIMIT = 50
 export const PLAYLIST_LIMIT = 20
 
-export const getRecordingListLimit = (isPlaylist?: boolean): number => (isPlaylist ? PLAYLIST_LIMIT : TABLE_LIMIT)
-
 export const DEFAULT_DURATION_FILTER: RecordingDurationFilter = {
     type: 'recording',
     key: 'duration',
@@ -56,7 +53,6 @@ export const DEFAULT_ENTITY_FILTERS = {
 }
 
 export interface SessionRecordingTableLogicProps {
-    isPlaylist?: boolean
     personUUID?: PersonUUID
     key?: string
 }
@@ -64,17 +60,16 @@ export interface SessionRecordingTableLogicProps {
 export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
     path: (key) => ['scenes', 'session-recordings', 'sessionRecordingsListLogic', key],
     props: {} as SessionRecordingTableLogicProps,
-    key: (props) => `${props.key || props.personUUID || 'global'}-${props.isPlaylist ? 'playlist' : 'table'}`,
+    key: (props) => `${props.key || props.personUUID || 'global'}`,
     connect: {
         values: [teamLogic, ['currentTeamId']],
         actions: [eventUsageLogic, ['reportRecordingsListFetched', 'reportRecordingsListFilterAdded']],
     },
     actions: {
         getSessionRecordings: true,
-        openSessionPlayer: (sessionRecording: Pick<SessionRecordingType, 'id' | 'matching_events'> | null) => ({
-            sessionRecording,
+        setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({
+            id,
         }),
-        closeSessionPlayer: true,
         setEntityFilters: (filters: Partial<FilterType>) => ({ filters }),
         setPropertyFilters: (filters: AnyPropertyFilter[]) => {
             return { filters }
@@ -100,7 +95,7 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
                     const paramsDict = {
                         ...values.filterQueryParams,
                         person_uuid: props.personUUID ?? '',
-                        limit: getRecordingListLimit(props.isPlaylist),
+                        limit: PLAYLIST_LIMIT,
                     }
                     const params = toParams(paramsDict)
                     await breakpoint(100) // Debounce for lots of quick filter changes
@@ -122,7 +117,7 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
             actions.getSessionRecordings()
         },
     }),
-    reducers: ({ props }) => ({
+    reducers: ({}) => ({
         filtersEnabled: [
             false,
             {
@@ -135,10 +130,10 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
                 getSessionRecordingsSuccess: (_, { sessionRecordingsResponse }) => {
                     return [...sessionRecordingsResponse.results]
                 },
-                openSessionPlayer: (prevSessionRecordings, { sessionRecording }) => {
+                setSelectedRecordingId: (prevSessionRecordings, { id }) => {
                     return [
                         ...prevSessionRecordings.map((s) => {
-                            if (s.id === sessionRecording?.id) {
+                            if (s.id === id) {
                                 return {
                                     ...s,
                                     viewed: true,
@@ -151,11 +146,10 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
                 },
             },
         ],
-        partialSessionRecording: [
-            null as Pick<SessionRecordingType, 'id' | 'matching_events'> | null,
+        selectedRecordingId: [
+            null as SessionRecordingType['id'] | null,
             {
-                openSessionPlayer: (_, { sessionRecording }) => sessionRecording ?? null,
-                closeSessionPlayer: () => null,
+                setSelectedRecordingId: (_, { id }) => id ?? null,
             },
         ],
         entityFilters: [
@@ -179,8 +173,8 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
         offset: [
             0,
             {
-                loadNext: (previousOffset) => previousOffset + getRecordingListLimit(props.isPlaylist),
-                loadPrev: (previousOffset) => Math.max(previousOffset - getRecordingListLimit(props.isPlaylist), 0),
+                loadNext: (previousOffset) => previousOffset + PLAYLIST_LIMIT,
+                loadPrev: (previousOffset) => Math.max(previousOffset - PLAYLIST_LIMIT, 0),
                 setOffset: (_, { offset }) => offset,
             },
         ],
@@ -197,7 +191,7 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
             },
         ],
     }),
-    listeners: ({ actions, values, props }) => ({
+    listeners: ({ actions }) => ({
         setEntityFilters: () => {
             actions.getSessionRecordings()
         },
@@ -216,20 +210,16 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
         loadPrev: () => {
             actions.getSessionRecordings()
         },
-        getSessionRecordingsSuccess: () => {
-            if (props.isPlaylist && !values.partialSessionRecording?.id && values.sessionRecordings.length > 0) {
-                actions.openSessionPlayer(values.sessionRecordings[0])
-            }
-        },
     }),
     selectors: {
         activeSessionRecording: [
-            (s) => [s.partialSessionRecording, s.sessionRecordings],
-            (partialSessionRecording, sessionRecordings) => {
-                return (
-                    sessionRecordings.find((sessionRecording) => sessionRecording.id === partialSessionRecording?.id) ??
-                    null
-                )
+            (s) => [s.selectedRecordingId, s.sessionRecordings],
+            (selectedRecordingId, sessionRecordings): Partial<SessionRecordingType> | undefined => {
+                return selectedRecordingId
+                    ? sessionRecordings.find((sessionRecording) => sessionRecording.id === selectedRecordingId) || {
+                          id: selectedRecordingId,
+                      }
+                    : sessionRecordings[0]
             },
         ],
         hasPrev: [(s) => [s.offset], (offset) => offset > 0],
@@ -269,10 +259,10 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
             const hashParams: HashParams = {
                 ...router.values.hashParams,
             }
-            if (!values.partialSessionRecording?.id) {
+            if (!values.selectedRecordingId) {
                 delete hashParams.sessionRecordingId
             } else {
-                hashParams.sessionRecordingId = values.partialSessionRecording.id
+                hashParams.sessionRecordingId = values.selectedRecordingId
             }
 
             return [router.values.location.pathname, params, hashParams, { replace }]
@@ -280,8 +270,7 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
 
         return {
             loadSessionRecordings: () => buildURL(true),
-            openSessionPlayer: () => buildURL(false),
-            closeSessionPlayer: () => buildURL(false),
+            setSelectedRecordingId: () => buildURL(false),
             setEntityFilters: () => buildURL(true),
             setPropertyFilters: () => buildURL(true),
             setDateRange: () => buildURL(true),
@@ -294,8 +283,8 @@ export const sessionRecordingsListLogic = kea<sessionRecordingsListLogicType>({
     urlToAction: ({ actions, values, props }) => {
         const urlToAction = (_: any, params: Params, hashParams: HashParams): void => {
             const nulledSessionRecordingId = hashParams.sessionRecordingId ?? null
-            if (nulledSessionRecordingId && nulledSessionRecordingId !== values.partialSessionRecording?.id) {
-                actions.openSessionPlayer({ id: nulledSessionRecordingId })
+            if (nulledSessionRecordingId !== values.selectedRecordingId) {
+                actions.setSelectedRecordingId(nulledSessionRecordingId)
             }
 
             const filters = params.filters
diff --git a/frontend/src/styles/utilities.scss b/frontend/src/styles/utilities.scss
index 064f2f887d0d5..45d680b7c63a4 100644
--- a/frontend/src/styles/utilities.scss
+++ b/frontend/src/styles/utilities.scss
@@ -768,6 +768,25 @@
     object-fit: contain;
 }
 
+.tracking-tighter {
+    letter-spacing: -0.05em;
+}
+.tracking-tight {
+    letter-spacing: -0.025em;
+}
+.tracking-normal {
+    letter-spacing: 0em;
+}
+.tracking-wide {
+    letter-spacing: 0.025em;
+}
+.tracking-wider {
+    letter-spacing: 0.05em;
+}
+.tracking-widest {
+    letter-spacing: 0.1em;
+}
+
 .select-none {
     user-select: none;
 }
diff --git a/frontend/src/styles/vars.scss b/frontend/src/styles/vars.scss
index 39709242fad57..df34b1e7bc810 100644
--- a/frontend/src/styles/vars.scss
+++ b/frontend/src/styles/vars.scss
@@ -91,6 +91,7 @@ $colors: (
     'white': #fff,
     'light': rgba(255, 255, 255, 0.878),
     'side': #fafaf9,
+    'mid': #f2f2f2,
     'border': rgba(0, 0, 0, 0.15),
     'border-light': rgba(0, 0, 0, 0.08),
     'border-dark': rgba(0, 0, 0, 0.24),
diff --git a/tailwind.config.js b/tailwind.config.js
index 202dc55b97a66..3f768bd629424 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -145,7 +145,7 @@ module.exports = {
         'justifyContent', // The justify-content utilities like justify-center
         // 'justifyItems', // The justify-items utilities like justify-items-end
         // 'justifySelf', // The justify-self utilities like justify-self-end
-        // 'letterSpacing', // The letter-spacing utilities like tracking-normal
+        'letterSpacing', // The letter-spacing utilities like tracking-normal
         'lineHeight', // The line-height utilities like leading-9
         // 'listStylePosition', // The list-style-position utilities like list-inside
         'listStyleType', // The list-style-type utilities like list-disc


Code review comments:
diff_hunk: @@ -1,123 +1,197 @@
 import React, { useRef } from 'react'
 import { useActions, useValues } from 'kea'
-import { colonDelimitedDuration } from '~/lib/utils'
+import { colonDelimitedDuration, range } from '~/lib/utils'
 import { SessionRecordingType } from '~/types'
-import { getRecordingListLimit, PLAYLIST_LIMIT, sessionRecordingsListLogic } from './sessionRecordingsListLogic'
+import { PLAYLIST_LIMIT, sessionRecordingsListLogic } from './sessionRecordingsListLogic'
 import { asDisplay } from 'scenes/persons/PersonHeader'
 import './SessionRecordingsPlaylist.scss'
-import { LemonTable, LemonTableColumns } from 'lib/components/LemonTable'
 import { TZLabel } from 'lib/components/TimezoneAware'
 import { SessionRecordingPlayer } from './player/SessionRecordingPlayer'
 import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage'
 import { LemonButton } from '@posthog/lemon-ui'
-import { IconChevronLeft, IconChevronRight } from 'lib/components/icons'
+import { IconChevronLeft, IconChevronRight, IconSchedule } from 'lib/components/icons'
 import { SessionRecordingsFilters } from './filters/SessionRecordingsFilters'
+import clsx from 'clsx'
+import { Tooltip } from 'lib/components/Tooltip'
+import { LemonSkeleton } from 'lib/components/LemonSkeleton'
+import { LemonTableLoader } from 'lib/components/LemonTable/LemonTableLoader'
 
 interface SessionRecordingsTableProps {
     personUUID?: string
     isPersonPage?: boolean
 }
 
+const DurationDisplay = ({ duration }: { duration: number }): JSX.Element => {
+    const formattedDuration = colonDelimitedDuration(duration)
+    const parts = formattedDuration.split(':')
+
+    return (
+        <span className="flex items-center gap-1">
+            <IconSchedule className="text-lg" />
+            <span>
+                <span className={parts[0] === '00' ? 'opacity-50' : ''}>{parts[0]}:</span>
path: frontend/src/scenes/session-recordings/SessionRecordingsPlaylist.tsx
text: @user1:
nit: use clsx