# 🎬 Video Player — Mini Player Flow Specification

> **Version:** 2.0 — Reflects actual Zinduka implementation  
> **Pattern:** Persistent Singleton DOM Node  
> **Reusable:** Yes — this spec is framework-portable; carry it to any Next.js / React project

---

## 📌 Overview

This document describes the complete lifecycle of the video player experience:

1. User opens `/video/[id]` → **Full player** renders
2. User clicks **← Back** → player **shrinks to mini** (bottom-right corner)
3. Video **continues playing from the exact same timestamp** — no pause, no restart
4. User clicks **⛶ Expand** on mini player → **full player restores** at same timestamp

The core trick is a **single `<video>` DOM element** that is physically _moved_ between
two slot `<div>`s using `appendChild`. Because the same element is reused, the browser
never re-initialises the media pipeline — `currentTime`, `paused`, `volume`, `buffered`,
and `playbackRate` are all preserved without any code.

---

## 🧭 User Flow Diagram

```
User clicks a video card
         │
         ▼
┌────────────────────────────┐
│   /video/[id]              │  ← Full screen video page
│   ┌──────────────────────┐ │
│   │   <video> element    │ │  ← Singleton video lives here
│   │   (playing at 1:00)  │ │
│   └──────────────────────┘ │
│   ← Back button            │
└────────────────────────────┘
         │
         │  handleBack() called:
         │    1. router.back()  (no store change needed)
         │    2. FullPlayer unmounts → video moves to "park" div
         │    3. MiniPlayer mounts  → video moves into mini slot
         ▼
┌────────────────────────────┐
│   /  (Home or any page)    │
│                            │
│                 ┌────────┐ │
│                 │ 📺1:00 │ │  ← Mini player (fixed, bottom-right)
│                 │ ▶  ⛶ ✕│ │     Video still playing from 1:00
│                 └────────┘ │
└────────────────────────────┘
         │
         │  User clicks ⛶ (Expand)
         │    router.push(`/video/${currentMediaId}`)
         ▼
┌────────────────────────────┐
│   /video/[id]              │
│   ┌──────────────────────┐ │
│   │   <video> element    │ │  ← Same element, still at 1:00
│   │   (still at 1:00)    │ │     No seek needed, no restart
│   └──────────────────────┘ │
└────────────────────────────┘
```

---

## 🏗️ Technology Stack

| Layer            | Technology                                           | Why                                                     |
| ---------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| Framework        | **Next.js 14 (App Router)**                          | File-based routing, server components                   |
| State Management | **Redux Toolkit** (`@reduxjs/toolkit`)               | Global player state (id, title, isPlaying, currentTime) |
| Media Strategy   | **Singleton DOM Node** (custom Context)              | Zero-restart video transfer between views               |
| Styling          | **Tailwind CSS** + inline styles                     | Rapid layout; animations via inline style transitions   |
| Icons            | **Lucide React** (`Maximize2`, `X`, `Play`, `Pause`) | Consistent icon set                                     |
| Router           | **`next/navigation`** (`useRouter`, `usePathname`)   | Programmatic navigation + path detection                |

---

## 📐 Architecture: Three Layers

```
┌──────────────────────────────────────────────────────────────┐
│  Layer 1: Redux Store  (store/slices/playerSlice.ts)         │
│  Holds: currentMediaId, type, isPlaying, currentTime,        │
│         title, channelName, thumbnail, isPiP                 │
└──────────────────────────────────────────────────────────────┘
         ▲ dispatch actions          ▼ useAppSelector reads

┌──────────────────────────────────────────────────────────────┐
│  Layer 2: PersistentPlayerContext  (lib/PersistentPlayerContext.tsx) │
│  Owns the singleton <video> DOM element.                     │
│  Exposes: mountVideo(slotEl) → cleanup()                     │
│  Internally: moves element via appendChild (zero-restart)    │
└──────────────────────────────────────────────────────────────┘
         ▲ mountVideo()              ▼ getVideoEl() direct access

┌──────────────────────────────────────────────────────────────┐
│  Layer 3: UI Components                                      │
│  • CustomVideoPlayer  → mounts in /video/[id] page          │
│  • MiniVideoPlayer    → fixed overlay in layout.tsx         │
│  • "Park" div         → off-screen holder between mounts    │
└──────────────────────────────────────────────────────────────┘
```

---

## 📁 File Structure

```
src/
 ├── lib/
 │   └── PersistentPlayerContext.tsx   ← Singleton <video> owner
 │
 ├── store/
 │   └── slices/
 │       └── playerSlice.ts            ← Redux player state
 │
 ├── components/
 │   └── layout/
 │       └── MiniVideoPlayer.tsx       ← Floating mini player
 │
 ├── components/
 │   └── player/
 │       └── CustomVideoPlayer.tsx     ← Full-screen player
 │
 └── app/
     ├── layout.tsx                    ← Mounts MiniVideoPlayer globally
     └── video/
         └── [id]/
             └── page.tsx              ← Mounts CustomVideoPlayer
```

---

## 🔧 Layer 1: Redux Store — `playerSlice.ts`

### State Shape

```ts
interface PlayerState {
  // ── Shared ──
  type: "video" | "audio" | "short" | null;
  isPlaying: boolean;
  volume: number;

  // ── Video ──
  currentMediaId: string | null;
  title: string;
  thumbnail: string;
  channelName: string;
  duration: number;
  currentTime: number; // synced from video element continuously
  videoUrl: string;
  videoContentUploadType: string;
  isPiP: boolean; // true = browser native PiP; false = custom mini

  // ── Audio (separate concern) ──
  currentTrack: MusicItem | null;
  queue: MusicItem[];
  progress: number;
  isShuffle: boolean;
  isRepeat: boolean;
}
```

### Key Actions for Mini Player Flow

| Action                    | Purpose                                                       |
| ------------------------- | ------------------------------------------------------------- |
| `playMedia(payload)`      | Start new video: sets id, url, title, resets currentTime to 0 |
| `registerMedia(payload)`  | Register video without auto-playing                           |
| `togglePlay()`            | Flip `isPlaying`                                              |
| `setCurrentTime(seconds)` | Sync `currentTime` from video element's `timeupdate` event    |
| `setDuration(seconds)`    | Store total duration                                          |
| `stopAll()`               | Reset everything — used by mini player Close (✕) button       |

### Why `currentTime` in Store?

The mini player reads `currentTime` from the video element directly (via `getVideoEl()`),
not the store. The store's `currentTime` is used when:

- The mini player is **closed** (saves position before `v.pause()`)
- A different component needs to read progress without direct element access

---

## 🔧 Layer 2: PersistentPlayerContext

### The Core Concept

```
Normal approach (bad):
  FullPlayer has <video src="..." />
  MiniPlayer has <video src="..." />
  → When switching, browser creates a NEW video → restarts from 0:00

Singleton approach (good):
  ONE <video> element exists for the entire app session
  It is moved (appendChild) between slot divs
  → No new element → no restart → currentTime preserved perfectly
```

### How `mountVideo` Works

```ts
// Called by FullPlayer or MiniPlayer on mount
const mountVideo = (container: HTMLElement): (() => void) => {
  const el = getVideoEl(); // get or create the singleton
  container.appendChild(el); // MOVE it into this component's slot

  return () => {
    // Cleanup: move back to the off-screen "park" div
    if (parkRef.current && el.parentNode !== parkRef.current) {
      parkRef.current.appendChild(el);
    }
  };
};
```

The `park` div sits at `position: fixed; top: -10000px; left: -10000px` — invisible,
but the video element stays alive and the browser keeps its internal state.

### Singleton Creation

```ts
let _singleton: HTMLVideoElement | null = null;

export function getVideoEl(): HTMLVideoElement {
  if (!_singleton) {
    _singleton = document.createElement("video");
    _singleton.playsInline = true;
    _singleton.preload = "metadata";
    _singleton.style.cssText =
      "width:100%;height:100%;display:block;object-fit:contain;position:absolute;top:0;left:0;";
  }
  return _singleton;
}
```

> Module-level variable — survives React's `StrictMode` double-invoke and HMR reloads.

### Provider Setup (`app/layout.tsx`)

```tsx
import { PersistentPlayerProvider } from "@/lib/PersistentPlayerContext";
import { MiniVideoPlayer } from "@/components/layout/MiniVideoPlayer";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <PersistentPlayerProvider>
          {children}
          <MiniVideoPlayer /> {/* Always in DOM, visible only when needed */}
        </PersistentPlayerProvider>
      </body>
    </html>
  );
}
```

---

## 🔧 Layer 3A: Full Player (`CustomVideoPlayer.tsx`)

### Responsibilities

1. Call `mountVideo(slotRef.current)` on mount → grabs the singleton video element
2. Set Redux `type = 'video'`, `mode = 'full'`
3. Attach `timeupdate` → `dispatch(setCurrentTime(v.currentTime))`
4. On unmount (back button pressed) → `mountVideo` cleanup runs → video moves to park

### Back Button Handler

```tsx
// In the video page or CustomVideoPlayer
const handleBack = () => {
  // No need to set mode — MiniVideoPlayer's visibility condition handles it
  // MiniVideoPlayer shows when: !isOnVideoPage && type === 'video' && !!currentMediaId
  router.back();
  // As soon as we leave /video/*, MiniVideoPlayer's shouldShow becomes true
  // → it calls mountVideo(miniSlot) → video moves from park to mini slot
  // → video never paused, currentTime preserved
};
```

> **Key insight:** There is no explicit "switch to mini" action. The mini player
> detects it should show based on `pathname` + Redux state, and mounts the video
> element itself.

### Object Fit Switch

```tsx
// FullPlayer: use 'contain' so letterboxing shows correctly
const v = getVideoEl();
v.style.objectFit = "contain";

// MiniPlayer: use 'cover' so the small frame looks better
v.style.objectFit = "cover";
// (restored to 'contain' in MiniPlayer's cleanup)
```

---

## 🔧 Layer 3B: Mini Player (`MiniVideoPlayer.tsx`)

### Visibility Logic

```tsx
const isOnVideoPage = pathname.startsWith("/video/");

const shouldShow =
  !isOnVideoPage && // not currently on the full player page
  !isPiP && // not in browser native PiP
  type === "video" && // media type is video (not audio/short)
  !!currentMediaId; // something is loaded
```

### Animation

```tsx
// CSS-driven spring animation — player slides up from bottom-right
<div
  style={{
    transition:
      "transform 0.38s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.28s ease",
    transform:
      visible && shouldShow
        ? "translateY(0) scale(1)"
        : "translateY(120%) scale(0.9)",
    opacity: visible && shouldShow ? 1 : 0,
    pointerEvents: visible && shouldShow ? "auto" : "none",
  }}
/>
```

The `visible` boolean is set via a 40ms `setTimeout` after `shouldShow` becomes true —
this ensures the element is rendered before the enter animation starts (avoids flash).

### Layout

```
Fixed position: bottom: 24px, right: 24px, z-index: 50

┌────────────────────────────────┐
│                  [⛶] [✕]      │  ← Top controls (on hover)
│                                │
│        VIDEO  (320×180)        │  ← Shared <video> element, 16:9
│                                │
│  Title text                    │  ← Bottom info bar
│  Channel name                  │
└────────────────────────────────┘
  ↑ bottom: 24px, right: 24px
```

### Dimensions

| Property      | Value                                                      |
| ------------- | ---------------------------------------------------------- |
| Width         | `320px`                                                    |
| Height        | `180px` (16:9 ratio)                                       |
| Position      | `fixed`                                                    |
| Bottom        | `24px`                                                     |
| Right         | `24px`                                                     |
| Border Radius | `12px`                                                     |
| Z-Index       | `50`                                                       |
| Background    | `#000`                                                     |
| Box Shadow    | `0 24px 64px rgba(0,0,0,0.72), 0 4px 16px rgba(0,0,0,0.5)` |

### Controls

| Control        | Location             | Action                                                                        |
| -------------- | -------------------- | ----------------------------------------------------------------------------- |
| ▶ / ⏸          | Center (on hover)    | `v.play()` / `v.pause()` + `dispatch(togglePlay())`                           |
| ⛶ Expand       | Top-right (on hover) | `router.push('/video/${currentMediaId}')`                                     |
| ✕ Close        | Top-right (on hover) | `dispatch(setCurrentTime(v.currentTime))`, `v.pause()`, `dispatch(stopAll())` |
| Click anywhere | Whole card           | Same as ⛶ Expand                                                              |

### Expand Handler

```tsx
const handleExpand = (e: React.MouseEvent) => {
  e.stopPropagation();
  collapse(); // collapse sidebar (UX polish)
  router.push(`/video/${currentMediaId}`); // navigate to full player
  // On arrival at /video/*, FullPlayer mounts and calls mountVideo()
  // → video element moves from mini slot to full player slot
  // → playback continues from exact same timestamp
};
```

### Close Handler

```tsx
const handleClose = (e: React.MouseEvent) => {
  e.stopPropagation();
  const v = getVideoEl();
  if (v) {
    dispatch(setCurrentTime(v.currentTime)); // save position to store before stopping
    v.pause();
  }
  dispatch(stopAll()); // clears currentMediaId → shouldShow becomes false → mini hides
};
```

---

## 🔄 Complete State Transition Table

| Event                   | Before           | After                            | Video Behavior                               |
| ----------------------- | ---------------- | -------------------------------- | -------------------------------------------- |
| User clicks video card  | `type=null`      | `type='video', currentMediaId=X` | Starts from 0:00                             |
| FullPlayer mounts       | video in park    | video in full player slot        | Continues / plays                            |
| User presses Back       | on `/video/*`    | on prev page                     | **Continues playing** — mini appears         |
| MiniPlayer mounts       | video in park    | video in mini slot               | **No restart, same timestamp**               |
| User clicks ⛶ Expand    | on browse page   | navigates to `/video/X`          | **Continues playing** — full player restores |
| FullPlayer mounts again | video in mini    | video in full player slot        | **No restart, same timestamp**               |
| User clicks ✕ Close     | `isPlaying=true` | `type=null, currentMediaId=null` | Paused, mini hidden                          |
| User plays audio        | `type='video'`   | `type='audio'`                   | Video stopped                                |

---

## ⚙️ Mount / Unmount Sequence

### Full → Mini (Back Button)

```
1. handleBack() → router.back()
2. React: FullPlayer component unmounts
3. mountVideo cleanup runs → video.parentNode = parkDiv
4. shouldShow becomes true (pathname no longer /video/*)
5. setVisible(true) fires after 40ms
6. MiniVideoPlayer useEffect: mountVideo(miniSlotRef) runs
7. video.parentNode = miniSlotDiv
8. video continues playing, currentTime unchanged
```

### Mini → Full (Expand Button)

```
1. handleExpand() → router.push('/video/X')
2. React: /video/[id] page mounts
3. shouldShow becomes false (pathname is /video/*)
4. MiniVideoPlayer mountVideo cleanup runs → video.parentNode = parkDiv
5. FullPlayer useEffect: mountVideo(fullSlotRef) runs
6. video.parentNode = fullPlayerSlotDiv
7. video continues playing, currentTime unchanged
```

> **Order guarantee:** React unmounts before mounts in the same render cycle.
> The park div bridges the gap so the video is never parentless.

---

## ⚠️ Edge Cases

| Case                                                   | Handling                                                                                      |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| User presses back without ever playing                 | `type` is `null` → `shouldShow = false` → mini never appears                                  |
| User opens different video while mini is active        | `playMedia` resets `currentMediaId` + `currentTime = 0` → new video starts fresh in full mode |
| User navigates to `/video/[same-id]` with mini visible | `shouldShow` flips to `false` → FullPlayer mounts → video moves from mini to full seamlessly  |
| Audio playing, user navigates back from video          | `type = 'audio'` → `shouldShow = false` for video mini player; audio bar is separate          |
| Mobile swipe-back gesture                              | Same as back button — React Router handles navigation event                                   |
| Browser native PiP activated                           | `isPiP = true` → `shouldShow = false` → custom mini player hidden to avoid overlap            |
| SSR / hydration                                        | All player components use `'use client'`; `getVideoEl()` only runs in browser                 |
| React StrictMode double-invoke                         | Module-level `_singleton` prevents duplicate video element creation                           |
| `position: fixed` overlap with bottom nav              | Use `bottom: 80px` if the app has a bottom navigation bar (adjust per layout)                 |

---

## 🎨 Animation Details

### Enter Animation (mini appears)

```
shouldShow = false → true
  │
  ├─ 40ms delay (setTimeout) to let DOM render first
  │
  └─ visible = true
       │
       └─ CSS transition fires:
            transform: translateY(120%) scale(0.9) → translateY(0) scale(1)
            opacity: 0 → 1
            duration: 0.38s  easing: cubic-bezier(0.34, 1.56, 0.64, 1)  [spring bounce]
```

### Exit Animation (mini disappears)

```
shouldShow = false
  │
  └─ visible = false immediately
       │
       └─ CSS transition fires:
            transform: translateY(0) scale(1) → translateY(120%) scale(0.9)
            opacity: 1 → 0
            duration: 0.38s / 0.28s
       pointerEvents: 'none' → prevents interaction during exit
```

### Optional: Expand Transition (View Transitions API)

```tsx
// Only works in Chrome 111+ / Safari 18+
const handleExpand = (e: React.MouseEvent) => {
  e.stopPropagation();
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      router.push(`/video/${currentMediaId}`);
    });
  } else {
    router.push(`/video/${currentMediaId}`);
  }
};
```

---

## ✅ Implementation Checklist

### Store

- [ ] `store/slices/playerSlice.ts` — `currentMediaId`, `type`, `isPlaying`, `currentTime`, `isPiP`
- [ ] Actions: `playMedia`, `registerMedia`, `togglePlay`, `setCurrentTime`, `stopAll`, `setPiP`

### Persistent Player Context

- [ ] `lib/PersistentPlayerContext.tsx` — module-level `_singleton` HTMLVideoElement
- [ ] `getVideoEl()` exported function — creates element once
- [ ] `PersistentPlayerProvider` — holds off-screen "park" `<div>` ref
- [ ] `mountVideo(container)` — moves element, returns cleanup function
- [ ] Park div: `position: fixed; top: -10000px; left: -10000px; width: 1px; height: 1px`

### Full Player Component

- [ ] Uses `usePersistentPlayer().mountVideo(slotRef)` on mount
- [ ] Slot `<div>` with `position: relative` fills player area
- [ ] `timeupdate` → `dispatch(setCurrentTime(v.currentTime))`
- [ ] Back button: just `router.back()` — no mode switch needed
- [ ] On mount: set `objectFit = 'contain'`

### Mini Player Component

- [ ] Mounted once in `app/layout.tsx` (or root layout)
- [ ] `shouldShow` condition: `!isOnVideoPage && !isPiP && type === 'video' && !!currentMediaId`
- [ ] 40ms delay before setting `visible = true` (prevents animation flash)
- [ ] CSS transition on `transform` + `opacity` for enter/exit
- [ ] `pointerEvents: 'none'` when hidden
- [ ] `mountVideo(videoSlotRef)` in `useEffect([shouldShow])`
- [ ] On mount: set `objectFit = 'cover'`; restore `'contain'` in cleanup
- [ ] Play/pause handler: `v.play()` / `v.pause()` + dispatch
- [ ] Expand handler: `router.push('/video/${currentMediaId}')`
- [ ] Close handler: save `currentTime`, `v.pause()`, `dispatch(stopAll())`
- [ ] Hover state for showing controls overlay
- [ ] Dimensions: 320×180px, fixed, bottom-right

### Layout

- [ ] `PersistentPlayerProvider` wraps entire app in root layout
- [ ] `<MiniVideoPlayer />` rendered inside provider, outside `{children}`

### Testing

- [ ] Back from video → mini appears at correct timestamp
- [ ] Expand mini → full player at correct timestamp
- [ ] Close mini → no mini, no playback
- [ ] New video while mini active → new video starts fresh
- [ ] Same video card clicked while mini active → restores full seamlessly
- [ ] Audio play while video mini active → mini disappears

---

## 📦 Dependency Summary

```json
{
  "dependencies": {
    "next": "^14.x",
    "@reduxjs/toolkit": "^2.x",
    "react-redux": "^9.x",
    "lucide-react": "^0.x"
  }
}
```

No special media library needed — this uses the native browser HTML5 `<video>` element.

---

## 🔗 Related Specs

- `video-player-full.md` — Full player UI, controls, progress bar
- `global-media-controller.md` — Cross-media stop logic (video/audio/shorts)
- `player-store.md` — Complete Redux player slice API reference
