وقتی پخش، **زنده** است و بازپخشی در کار نیست، **لحظهای که از دست بدهید از دست رفته است!** چیزی که کم است یک پخشکنندهٔ ساده نیست، بلکه ابزاری است برای برگشتن: عقب و جلو رفتن روی خط زمان، پخش آهسته، تکرار یک بازهٔ کوچک و پریدن مستقیم به لحظههای مهم بازی! در این سوال باید همین پخشکننده را زنده کنید.

**هدف این سوال** پیادهسازی فقط و فقط **منطق و رفتار برنامه** با `JavaScript` است. **کل ظاهر برنامه از قبل ساخته شده است**؛ اگر همین حالا `index.html` پروژهٔ اولیه را در مرورگر باز کنید، دقیقاً همان صفحهای را میبینید که در تصویرهای این صورت سوال آمده است. هیچ `HTML` و `CSS` جدیدی ننویسید و ظاهر را تغییر ندهید؛ سیستم داوری کوئرا در این سوال **اصلاً ظاهر را بررسی نمیکند** و فقط رفتار پخشکننده را میسنجد.
# **پروژهٔ اولیه**
برای دانلود پروژهٔ اولیه روی [این لینک](/contest/assignments/103143/download_problem_initial_project/356839/) کلیک کنید.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">index.html</mark>
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">styles.css</mark>
└─ modules/
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">data.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">format.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">track.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">playhead.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">chapters.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">trim.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">speed.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">clock.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">loop.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">filmstrip.js</mark>
└─ <mark class="orange" title="این فایل را تکمیل کنید">app.js</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** فایلهای سبز آمادهاند و **نباید تغییرشان بدهید**: `index.html` کل نشانهگذاری صفحه با همهٔ `data-testid`ها، `styles.css` کل طراحی و `data.js` دادهٔ ثابت فیلم. فایلهای نارنجی همانهاییاند که باید تکمیلشان کنید و همگی منطق اصلی این سوال هستند.
+ **نکته:** فایل `app.js` تنها جایی است که اجازه دارد به `DOM` دست بزند و بقیهٔ ماژولها باید **کاملاً مستقل از صفحه بمانند.** در `app.js` هم نشانی تکتک عنصرهای صفحه از قبل در شیء `el` پیدا شده است، پس هیچوقت لازم نیست دنبال سلکتور بگردید؛ فقط منطق را مینویسید. نام فایلها و نام `export`ها را **تغییر ندهید،** چون سیستم داوری همین مسیرها را مستقیم `import` میکند.
</details>
<details class="red">
<summary>**هشدار: هیچ فایلی را حذف نکنید**</summary>
سیستم داوری این نُه ماژول را مستقیم `import` میکند: `clock` و `track` و `playhead` و `chapters` و `loop` و `trim` و `speed` و `filmstrip` و `format`. اگر حتی یکی از آنها نباشد، سیستم داوری **نمرهٔ صفر را لحاظ خواهد کرد**. فایل `app.js` را خود صفحه بارگذاری میکند و بدون آن، تستهای مربوط به رفتار رابط کاربری نمرهای دریافت نخواهند کرد.
</details>
# **جزئیات پیادهسازی**
در این سوال، یک پخشکنندهٔ ویدیو میسازید که **ویدیو ندارد!** بهجای فیلم واقعی، فقط یک خط زمان با چند کلیپ دارید و همهٔ قابلیتهای یک پخشکنندهٔ ویدیو را روی همین خط زمان پیاده میکنید.
|  |
| :-: |
| جابهجایی روی خط زمان و تغییر کلیپ فعال؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
+ **نکته:** منطق در ده ماژول جدا نوشته میشود و **هیچکدام نباید به `DOM` یا `document` دسترسی داشته باشند**. فقط `app.js` با `document` کار دارد.
+ **نکته:** هیچ تابعی نباید آرایه یا شیء ورودیاش را تغییر بدهد. هر تابعی که وضعیت تازهای میسازد، باید نسخهٔ تازهای از همان ساختار برگرداند؛ توابعی مثل قالببندی زمان فقط یک مقدار ساده برمیگردانند.
<details class="green">
<summary>**نکته: شکل دادهها را قبل از شروع بخوانید**</summary>
چهار ساختار زیر در کل برنامه دستبهدست میشوند:
```js
// clip
{ id: "opening-goal", label: "Opening Goal", kind: "goal", in: 19, out: 30 }
// track
{ clips: [ /* sorted by in-time */ ] }
// playhead
{ time: 0, duration: 40 }
// chapter
{ id: "a", time: 10, label: "B", index: 1 }
// loop
{ a: 5, b: 12 }
```
> **کلیپ** یک تکه از فیلم است؛ `id` شناسهٔ یکتا، `label` متن نمایشی، `kind` نوع رویداد و `in` و `out` ابتدا و انتهای آن بر حسب ثانیه. کلیپها فاصله ندارند، یعنی `out` هر کلیپ برابر `in` کلیپ بعدی است. **`track`** فقط آرایهای از کلیپهاست که همیشه بر اساس زمان شروع مرتب نگه داشته میشود؛ توابع `track.js` فرض میکنند این ترتیب برقرار است، پس هر تابعی که کلیپ اضافه یا جابهجا میکند باید ترتیب را دوباره برقرار کند.
>
> **نشانگر پخش** نقطهای است که هماکنون روی خط زمان روی آن ایستادهاید و طول کل فیلم را هم با خودش دارد؛ `time` هیچوقت نباید از `0` کمتر یا از `duration` بیشتر شود. **فصل** نشانگری روی خط زمان است که `time` آن لحظهٔ هدف و `index` جایگاهش در فهرست مرتبشده است. **لوپ** هم بازهای است که پخش داخل آن تکرار میشود؛ تا وقتی هر دو نقطه گذاشته نشده باشند مقدارشان `null` است و لوپ هیچ اثری روی پخش ندارد. مقدار `null` اینجا با `0` فرق دارد، چون `0` یعنی نقطه روی ثانیهٔ صفر گذاشته شده است.
</details>
<details class="green">
<summary>**بهتر است پیادهسازی را از کجا شروع کنیم؟**</summary>
1. `format.js` و `data.js` مختصر هستند و بقیه به آنها نیاز دارند.
2. `track.js` و `playhead.js` مدلهای اصلی هستند.
3. `chapters.js` و `trim.js` و `speed.js` و `loop.js` هرکدام مستقل از همدیگر هستند.
4. `clock.js` و `filmstrip.js` پیادهسازی مختصری دارند.
5. و آخر از همه به سراغ `app.js` بروید تا همهچیز را به صفحهٔ آماده وصل کنید!
</details>
<details class="blue">
<summary>**پیادهسازی فایل `data.js` (کلیپهای فیلم)**</summary>
**این فایل هیچ منطقی ندارد** و فقط دادهٔ ثابت برنامه را نگه میدارد. فیلم بازی از هشت کلیپ پشت سر هم ساخته شده که روی یک خط زمان ۶۴ ثانیهای بدون فاصله کنار هم نشستهاند: هر کلیپ از همانجایی شروع میشود که کلیپ قبلی تمام شده است.
هر کلیپ پنج فیلد دارد. مقدار `id` شناسهٔ یکتای کلیپ است و در `data-testid`ها هم استفاده میشود، `label` متنی است که روی صفحه نمایش داده میشود، `kind` نوع رویداد را مشخص میکند (برای رنگ و آیکون به کار میآید) و `in` و `out` لحظهٔ شروع و پایان کلیپ روی خط زماناند، بر حسب ثانیه.
**دقیقاً همین فهرست را با همین ترتیب و همین مقادیر تعریف کنید:**
```js modules/data.js js
export const REEL = [
{ id: "kickoff", label: "Kickoff", kind: "start", in: 0, out: 6 },
{ id: "first-chance", label: "First Chance", kind: "shot", in: 6, out: 14 },
{ id: "yellow-card", label: "Yellow Card", kind: "card", in: 14, out: 19 },
{ id: "opening-goal", label: "Opening Goal", kind: "goal", in: 19, out: 30 },
{ id: "near-equalizer", label: "Near Equalizer", kind: "shot", in: 30, out: 38 },
{ id: "great-save", label: "Great Save", kind: "save", in: 38, out: 46 },
{ id: "second-goal", label: "Second Goal", kind: "goal", in: 46, out: 58 },
{ id: "final-whistle", label: "Final Whistle", kind: "end", in: 58, out: 64 },
];
export const STORAGE_KEY = "road-highlight-replay";
```
> ترتیب `REEL` اهمیت دارد، چون بقیهٔ ماژولها فرض میکنند کلیپها از قبل بر اساس زمان مرتباند. مقدار `out` هر کلیپ برابر `in` کلیپ بعدی است، پس خط زمان حفره ندارد و طول کل فیلم `64` ثانیه است. ثابت `STORAGE_KEY` هم کلیدی است که وضعیت پخش زیر آن در `localStorage` ذخیره میشود.
>
> **این مقادیر را به هیچ عنوان تغییر ندهید.** سیستم داوری روی همین هشت شناسه و همین زمانها کد شما را تست میکند و انتظار دارد `opening-goal` دقیقاً از ثانیهٔ `19` تا `30` باشد؛ جابهجا یا حذف کردن یک کلیپ، تستهای فصلها و نوار `filmstrip` را هم صفر میکند.
</details>
<details class="green">
<summary>**پیادهسازی فایل `format.js` (زمان و درصد)**</summary>
**این ماژول کوچکترین ماژول برنامه است** ولی بقیه بیشتر از همه به آن تکیه میکنند، چون دو کار پرتکرار را در یک جا جمع میکند: **تبدیل عدد به متنی** که روی صفحه دیده میشود و **تبدیل زمان به نسبت و برعکس** تا بشود موقعیتها را روی نوار زمان حساب کرد. هیچکدام از این توابع وضعیتی نگه نمیدارند و هیچکدام به `DOM` دست نمیزنند؛ ورودی میگیرند و خروجی میدهند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `clamp(value, min, max)`:** مقدار را داخل بازهٔ بسته نگه میدارد و عدد برمیگرداند. خودِ دو مرز مجازند، پس `clamp(10, 0, 10)` همان `10` است. یک حالت مرزی دارد که تستها میسنجند: اگر ورودی عدد نباشد، باید `min` برگردد نه `NaN`. این تابع در چند ماژول دیگر هم استفاده میشود، پس خطا در آن جای دیگری خودش را نشان میدهد.
- **تابع `formatTime(seconds)`:** ثانیه را به قالب `mm:ss.cs` تبدیل میکند، یعنی دقیقه و ثانیه هرکدام **دو رقمی با صفر ابتدایی**، بعد یک نقطه و بعد صدم ثانیه که آن هم دو رقمی است. این رشته زیر خط زمان نمایش داده میشود و تستها آن را کاراکتربهکاراکتر میسنجند، پس ورودی `9` باید `"00:09.00"` بدهد و نه `"0:9.0"`.
- **تابع `formatSpeed(multiplier)`** ضریب سرعت را برای نمایش زیر کنترل سرعت آماده میکند و یک `x` به انتهایش میچسباند. صفر اضافه نمیگذارد، یعنی برای ضریب `1` خروجی `"1x"` است نه `"1.0x"`.
- **توابع `timeToFraction(time, duration)` و `fractionToTime(fraction, duration)`** این دو قرینهٔ هماند و کارشان تبدیل میان زمان و موقعیت نسبی روی نوار است. اولی زمان را به نسبتی بین `0` و `1` تبدیل میکند و دومی همان نسبت را به زمان برمیگرداند. خروجی هر دو **عدد** است، نه رشته. حالت مرزی مهمشان تقسیم بر صفر است: وقتی `duration` صفر باشد، `timeToFraction` باید `0` بدهد نه `NaN` و نه `Infinity`.
- **تابع `percent(fraction)`** نسبتی بین `0` و `1` را به رشتهٔ درصد تبدیل میکند. خروجی مستقیم داخل `style.width` یک عنصر مینشیند، پس حتماً باید رشتهای مثل `"25%"` باشد و نه عدد؛ اگر عدد برگردانید، نوار پیشرفت روی صفحه اصلاً رشد نمیکند.
```js modules/format.js js
clamp(-5, 0, 10) // 0
clamp(99, 0, 10) // 10
clamp(4, 0, 10) // 4
formatTime(0) // "00:00.00"
formatTime(9) // "00:09.00"
formatTime(75) // "01:15.00"
formatTime(75.5) // "01:15.50"
formatTime(3.07) // "00:03.07"
timeToFraction(20, 40) // 0.5
timeToFraction(20, 0) // 0
fractionToTime(0.25, 40) // 10
formatSpeed(0.5) // "0.5x"
percent(0.25) // "25%"
```
> به صفرهای ابتدایی `formatTime` دقت کنید: ورودی `9` باید `"00:09.00"` بدهد نه `"0:9.0"` و ورودی `3.07` باید `"00:03.07"` بدهد یعنی صدم ثانیه هم دو رقمی است. خط `timeToFraction(20, 0)` هم حالت تقسیم بر صفر را نشان میدهد که باید `0` بدهد نه `NaN`. این توابع پایهٔ بقیهٔ برنامهاند، پس یک خطای کوچک اینجا در چند بخش دیگر هم خودش را نشان میدهد.
</details>
<details class="violet">
<summary>**پیادهسازی فایل `track.js` (مدل کلیپها)**</summary>
**این ماژول مدل دادهٔ پخشکننده است** فهرست کلیپها و هر پرسشی که دربارهٔ آن پیش میآید قرار است از این ماژول انجام شود. بقیهٔ ماژولها مستقیم سراغ آرایهٔ کلیپها **نمیروند** و همیشه از همین توابع میپرسند **«الان کدام کلیپ پخش میشود؟»** یا **«طول کل چقدر است؟».** هیچکدام از این توابع `track` ورودی را تغییر نمیدهند و هر تغییری یک `track` تازه میسازد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `makeClip(spec)`:** توصیف خام یک کلیپ را به کلیپ استاندارد تبدیل میکند، یعنی شیئی با پنج فیلد `id` و `label` و `kind` و `in` و `out`. کارش پر کردن جاهای خالی است: نبودِ `label` با `id` جبران میشود و نبودِ `kind` با مقدار پیشفرض `"play"`. یک تضمین هم میدهد که بقیهٔ ماژولها به آن تکیه میکنند: `out` هیچوقت کوچکتر از `in` نیست، پس کلیپ خروجی طول منفی ندارد.
- **تابع `createTrack(specs)`:** فهرستی از همان توصیفهای خام میگیرد و یک `track` برمیگرداند، یعنی شیئی به شکل `{ clips }`. هر توصیف از دل `makeClip` رد میشود و در پایان کلیپها **بر اساس زمان شروع** مرتب میشوند. یعنی ورودی میتواند به هر ترتیبی باشد و شما نباید در بقیهٔ ماژولها نگران نامرتب بودن کلیپها باشید.
- **تابع `trackDuration(track)`:** طول کل خط زمان را برمیگرداند. این عدد بزرگترین `out` میان همهٔ کلیپهاست، نه مجموع طول آنها. برای `track` خالی مقدار `0` برمیگردد.
- **توابع `clipAt(track, time)` و `clipIndexAt(track, time)`:** هر دو یک سؤال را جواب میدهند: در این لحظه کدام کلیپ پخش میشود؟ اولی خود شیء کلیپ را میدهد و دومی جایگاهش را در آرایه. تعریف «داخل کلیپ بودن» را دقیق پیاده کنید: لحظهٔ روی `in` داخل کلیپ حساب میشود ولی لحظهٔ روی `out` **نه**، چون به کلیپ بعدی تعلق دارد. اگر هیچ کلیپی آن لحظه را پوشش ندهد، `clipAt` مقدار `null` و `clipIndexAt` مقدار `-1` میدهد.
- **تابع `markers(track)`:** برای هر کلیپ یک نشانه روی زمان شروع آن میسازد. هر نشانه `id` و `time` و `label` و `kind` کلیپ را با خودش میآورد تا رابط کاربری بدون مراجعهٔ دوباره به کلیپها بتواند آن را با برچسب درست رسم کند. `chapters.js` هم ورودیاش را از همینجا میگیرد.
- **تابع `setClipWindow(track, id, inT, outT)`:** بازهٔ یک کلیپ را عوض میکند و یک `track` تازه برمیگرداند. ورودی جابهجا پذیرفته و خودش مرتب میشود و چون ممکن است کلیپ جابهجا شده باشد، کلیپهای خروجی دوباره بر اساس زمان شروع مرتب میشوند.
- **تابع `coveredLength(track)`:** مجموع زمانی را که دستکم یک کلیپ پوششش میدهد برمیگرداند و همپوشانیها را دوبار نمیشمارد؛ برای دو کلیپ `0..10` و `5..15` جواب `15` است نه `20`.
```js
const SPECS = [
{ id: "a", label: "A", kind: "start", in: 0, out: 10 },
{ id: "b", label: "B", kind: "goal", in: 10, out: 25 },
{ id: "c", label: "C", kind: "save", in: 25, out: 40 },
];
trackDuration(createTrack(SPECS)) // 40
clipAt(createTrack(SPECS), 15).id // "b"
clipIndexAt(createTrack(SPECS), 30) // 2
markers(createTrack(SPECS)).map(m => m.time) // [0, 10, 25]
```
> در نمونهٔ بالا زمان `15` داخل کلیپ `b` میافتد که بازهاش `10..25` است و زمان `30` در کلیپ سوم. مرزها همان جاییاند که اشتباه رخ میدهد: لحظهٔ `25` به کلیپ `c` تعلق دارد نه `b`، چون `out` هر کلیپ بیرون آن حساب میشود. خروجی `markers` هم زمان **شروع** هر کلیپ است، پس `[0, 10, 25]` میدهد.
</details>
<details class="orange">
<summary>**پیادهسازی فایل `playhead.js` (نشانگر پخش)**</summary>
نشانگر پخش همان خط عمودی است که روی نوار زمان جلو میرود و کل حالتش دو عدد است: زمان فعلی و طول کل. در **همهٔ این توابع** باید یک نکته رعایت شود: زمان فعلی هیچوقت از `0` کمتر و از `duration` بیشتر نمیشود. هیچکدام از این توابع هم شیء ورودی را تغییر نمیدهند و همیشه یک `playhead` تازه برمیگردانند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| نشانگر پخش و نشانههای فصل روی خط زمان |
- **تابع `createPlayhead(duration)`:** نشانگر تازهای میسازد که روی زمان صفر ایستاده است، یعنی شیئی با `time` برابر `0` و `duration` برابر مقدار دادهشده. مقدار `duration` منفی پذیرفته نمیشود و به `0` تبدیل میشود.
- **تابع `seek(playhead, time)`:** نشانگر را روی یک زمان مطلق میگذارد. این همان جایی است که مهار انجام میشود: زمان بزرگتر از `duration` روی انتهای خط زمان میایستد و زمان منفی روی صفر. بقیهٔ توابع جابهجایی در نهایت از همین تابع رد میشوند.
- **تابع `step(playhead, delta)`:** نشانگر را بهاندازهٔ `delta` ثانیه جلو میبرد؛ مقدار منفی یعنی عقب رفتن. همان مهار `seek` را رعایت میکند، پس عقب رفتن بیش از حد، نشانگر را روی صفر نگه میدارد.
- **توابع `seekFraction(playhead, fraction)` و `fraction(playhead)`:** این دو تابع تبدیل زمان و موقعیت روی نوار را انجام میدهند. اولی کسری بین `0` و `1` میگیرد و نشانگر را به همان نسبت از طول کل میبرد؛ کلیک کاربر روی نوار از همین رد میشود. دومی برعکس، موقعیت فعلی را بهصورت کسری بین `0` و `1` میدهد تا عرض نوار پیشرفت از رویش ساخته شود. وقتی `duration` صفر است، `fraction` باید `0` بدهد نه `NaN`.
- **تابع `advance(playhead, dt, speed)`:** نشانگر را بهاندازهٔ `dt` ثانیه ضربدر سرعت جلو میبرد و همان مهار را رعایت میکند؛ در هر فریمِ پخش صدا زده میشود. اگر `speed` داده نشود، سرعت عادی یعنی `1` فرض میشود.
- **تابع `atEnd(playhead)`:** میگوید نشانگر به انتهای خط زمان رسیده است یا نه و مقدار بولی برمیگرداند. برای نشانگری که `duration` آن صفر است، مقدار `false` برمیگردد؛ یعنی خط زمانِ خالی «تمامشده» حساب نمیشود.
- **تابع `setDuration(playhead, duration)`:** طول کل را عوض میکند. اگر زمان فعلی بیرون بازهٔ تازه بیفتد، باید تا انتهای بازهٔ جدید عقب کشیده شود تا قانون بالا نشکند.
```js
const p = createPlayhead(40);
seek(p, 99).time // 40
seek(p, -5).time // 0
step(seek(p, 10), 3).time // 13
seekFraction(p, 0.5).time // 20
fraction(seek(p, 10)) // 0.25
advance(p, 2, 1.5).time // 3
setDuration(seek(p, 30), 20).time // 20
```
> دو خط اول مهار را نشان میدهند: `99` روی `40` میایستد و `-5` روی `0`. در مثال سوم، خروجی `seek` مستقیماً به `step` داده شده است؛ چون این توابع شیء ورودی را تغییر نمیدهند، خروجی `seek` دوباره به `step` داده شده است. اگر بهجای این، `seek` و بعد `step` را جدا روی `p` صدا بزنید، جابهجایی دوم از زمان صفر شروع میشود نه از `10`.
</details>
<details class="teal">
<summary>**پیادهسازی فایل `chapters.js` (پریدن بین لحظهها)**</summary>
این ماژول همان چیزی را میسازد که در پخشکنندههای ویدیو به آن **«فصل»** یا **«لحظه»** میگویند: نقطههایی روی خط زمان که کاربر میتواند با دو دکمهٔ بعدی و قبلی بینشان بپرد. ورودیاش فهرست نشانههایی است که `markers` در `track.js` ساخته و خروجیاش یک آرایهٔ مرتب از فصلهاست. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| فهرست لحظهها و فصل فعال در میان آنها |
- **تابع `createChapters(markerList)`:** نشانهها را بر اساس زمان مرتب میکند و بعد به هر فصل فیلد `index` میدهد که جایگاهش در همان آرایهٔ مرتب است. شمارهگذاری **بعد از** مرتبسازی انجام میشود، نه بر اساس ترتیب ورودی.
- **توابع `activeIndex(chapters, time)` و `activeChapter(chapters, time)`:** میگویند در این لحظه کدام فصل فعال است؛ اولی جایگاهش را میدهد و دومی خود شیء فصل را. فصل فعال آخرین فصلی است که زمان شروعش از زمان فعلی گذشته باشد. یک حالت مرزی دارند: اگر هنوز به اولین فصل نرسیده باشیم، `activeIndex` مقدار `-1` و `activeChapter` مقدار `null` میدهد.
- **تابع `nextTime(chapters, time)`:** زمان اولین فصلی را میدهد که بعد از لحظهٔ فعلی شروع میشود. اگر فصلی جلوتر نمانده باشد `null` برمیگردد و رابط کاربری با همین `null` دکمه را غیرفعال میکند.
- **تابع `prevTime(chapters, time, epsilon = 0.001)`:** دکمهٔ «قبلی» در پخشکنندههای واقعی دو رفتار متفاوت دارد و هر دو را باید پیاده کنید:
+ اگر **وسط** یک فصل باشید، دکمهٔ قبلی شما را به **ابتدای همان فصل** برمیگرداند، نه به فصل قبل. یعنی رفتارش «از اول پخش کن» است.
+ اگر **دقیقاً روی شروع** یک فصل ایستاده باشید، دکمهٔ قبلی شما را به فصل **قبلی** میبرد.
پارامتر `epsilon` تعیین میکند چقدر فاصله از شروع فصل هنوز «روی شروع» حساب شود؛ بدون این تلورانس، خطای ممیز شناور نشانگری را که عملاً روی شروع فصل است «وسط فصل» تشخیص میدهد. اگر هیچ فصلی قبلتر نمانده باشد، خروجی `null` است.
- **تابع `count(chapters)`:** تعداد فصلها را برمیگرداند.
```js
const ch = createChapters([
{ id: "a", time: 0, label: "A" },
{ id: "b", time: 10, label: "B" },
{ id: "c", time: 25, label: "C" },
]);
activeIndex(ch, 12) // 1
activeIndex(ch, 26) // 2
nextTime(ch, 12) // 25
nextTime(ch, 30) // null
prevTime(ch, 14) // 10 (restart the current chapter)
prevTime(ch, 10) // 0 (jump back one chapter)
prevTime(ch, 0) // null
```
> این سه مثال هر دو رفتار دکمهٔ «قبلی» را نشان میدهند. از زمان `14` که وسط فصل `B` است، خروجی `10` یعنی ابتدای همان فصل؛ ولی از زمان `10` که دقیقاً روی شروع `B` ایستادهایم، خروجی `0` یعنی فصل قبل. روی اولین فصل هم چیزی قبلتر نمانده و `null` برمیگردد.
</details>
<details class="purple">
<summary>**پیادهسازی فایل `trim.js` (بریدن کلیپ)**</summary>
هر کلیپ دو عدد `in` و `out` دارد که میگویند کدام بخش از آن پخش شود. کاربر میتواند این دو سر را جابهجا کند تا فقط تکهٔ موردنظرش بماند؛ به این کار **برش** میگوییم. این ماژول همین دو عدد را میسنجد و تغییر میدهد و هیچ کاری با پخش یا صفحه ندارد.
پارامتر `minLength` که در سه تابع زیر تکرار شده یک معنا دارد: **بازه هیچوقت نباید کوتاهتر از این مقدار شود.** بدون آن، کاربر میتوانست دو سر را روی هم بگذارد و کلیپی با طول صفر بسازد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `validateWindow(inT, outT, duration)`:** بررسی میکند بازهٔ پیشنهادی معتبر است یا نه و شیء `{ ok, reason }` برمیگرداند؛ برای بازهٔ معتبر `reason` برابر `null` است. این تابع فقط **قضاوت** میکند و چیزی را تغییر نمیدهد؛ خروجیاش برای نمایش پیام خطا به کار میآید.
- **تابع `applyTrim(clip, inT, outT, duration, minLength = 0.1)`:** برش را اعمال میکند و **یک کلیپ تازه** برمیگرداند؛ فیلدهایی مثل `id` و `label` سر جایشان میمانند و فقط `in` و `out` عوض میشوند. برخلاف `validateWindow` ورودی نامرتب را رد نمیکند و خروجیاش همیشه سه شرط را برآورده میکند: `in` از `out` کوچکتر است، هر دو داخل `0` تا `duration` میمانند و فاصلهشان دستکم `minLength` است.
- **توابع `setIn(clip, time, minLength = 0.1)` و `setOut(clip, time, minLength = 0.1)`:** هرکدام فقط یک سر بازه را جابهجا میکنند و کلیپ تازه برمیگردانند. اگر سر تازه به سر دیگر نزدیکتر از `minLength` شود، سر دیگر هم به همان اندازه هل داده میشود تا حداقل طول حفظ بماند. هیچکدام از دو سر زیر صفر نمیروند.
- **تابع `windowLength(clip)`:** طول بازه یعنی فاصلهٔ `in` تا `out` را برمیگرداند و هیچوقت عدد منفی نمیدهد.
بازه در چهار حالت نامعتبر است و برای هرکدام `reason` مقدار مشخصی دارد:
| **حالت** | **مقدار** `reason` |
| --: | --: |
| یکی از دو مقدار عدد نیست | `"non-numeric"` |
| یکی از دو مقدار منفی است | `"negative"` |
| مقدار `out` از طول کل بیشتر است | `"out-of-range"` |
| مقدار `in` از `out` کوچکتر نیست | `"inverted"` |
ترتیب این بررسیها اهمیت دارد و باید از بالا به پایین همین جدول باشد، چون یک ورودی میتواند همزمان چند شرط را بشکند و تستها انتظار دارند اولین دلیل گزارش شود.
```js modules/trim.js js
validateWindow(2, 8, 40) // { ok: true, reason: null }
validateWindow(8, 2, 40).reason // "inverted"
validateWindow(2, 50, 40).reason // "out-of-range"
validateWindow(-1, 8, 40).reason // "negative"
validateWindow(NaN, 8, 40).reason // "non-numeric"
applyTrim({ id: "a", in: 0, out: 10 }, 6, 2, 40)
// { id: "a", in: 2, out: 6 } <- swapped inputs get sorted, id is kept
setIn({ id: "a", in: 0, out: 5 }, 4.95)
// { id: "a", in: 4.95, out: 5.05 } <- out was pushed to keep minLength
windowLength({ in: 2, out: 7 }) // 5
```
> دو تابع اصلی این ماژول را با یک ورودی مقایسه کنید: بازهٔ جابهجای `(8, 2)` از دید `validateWindow` نامعتبر است و `"inverted"` میگیرد، ولی `applyTrim` با همان ورودی کار میکند و خودش مرتبش میکند. مثال `setIn` هم نقش `minLength` را نشان میدهد: سر ابتدا روی `4.95` فقط `0.05` با `out` فاصله دارد، پس `out` تا `5.05` هل داده میشود. به فیلد `id` در خروجیها دقت کنید؛ کلیپ کپی میشود و بقیهٔ فیلدها سر جایشان میمانند.
|  |
| :-: |
| بازهٔ برش مشخصشده روی یک کلیپ |
</details>
<details class="yellow">
<summary>**پیادهسازی فایل `speed.js` (سرعت پخش)**</summary>
این ماژول دو کار جدا انجام میدهد که بهتر است از اول از هم تفکیکشان کنید. **مهار** یعنی هیچ عددی نتواند سرعت را از دو سر بازه بیرون ببرد و **پلهای کردن** یعنی هر مقدار دلخواه به یکی از پنج پلهٔ مجاز بچسبد، چون نوار سرعت پیوسته نیست. این توابع حالت برنامه را نگه نمیدارند و فقط روی سرعت فعلی حساب میکنند.
|  |
| :-: |
| انتخاب سرعت پخش از میان مقادیر مجاز |
```js
export const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
```
> این آرایه پلههای مجاز سرعت پخش است و ترتیبش از کند به تند اهمیت دارد، چون دکمههای سرعت بر اساس همین ترتیب بین پلهها جابهجا میشوند. مقدار `1` سرعت عادی است و باید حتماً در فهرست باشد.
- **تابع `clampSpeed(value)`:** عدد ورودی را همانطور برمیگرداند، مگر اینکه از کف یا سقف `SPEEDS` بیرون زده باشد که در آن صورت به نزدیکترین سر بازه میچسبد. این تابع **پلهای نمیکند**؛ مقدار `0.8` که داخل بازه است دستنخورده برمیگردد. یک حالت مرزی هم دارد: اگر ورودی عدد نباشد، بهجای خطا یا `NaN` باید سرعت عادی یعنی `1` برگردد تا یک مقدار خراب از رابط کاربری پخش را قفل نکند.
- **تابع `snap(value)`:** نزدیکترین پلهٔ `SPEEDS` را برمیگرداند، پس خروجیاش **همیشه** یکی از همان پنج مقدار است. ورودی بیرون بازه هم پذیرفته میشود و به نزدیکترین سر میرسد. تفاوتش با `clampSpeed` همین است: آن یکی فقط جلوی بیرونزدن را میگیرد، این یکی مقدار را روی شبکهٔ پلهها مینشاند.
- **تابع `indexOf(value)`:** جایگاه یک سرعت را در `SPEEDS` میدهد؛ پلهٔ اول جایگاه `0` دارد. ورودی لازم نیست دقیقاً یکی از پلهها باشد و برای مقدار میانی هم جایگاه نزدیکترین پله برمیگردد، پس این تابع هیچوقت `-1` نمیدهد.
- **تابع `stepSpeed(value, direction)`:** یک پله بالا یا پایین میرود و پلهٔ تازه را برمیگرداند؛ `direction` مثبت یعنی تندتر و منفی یعنی کندتر. روی بالاترین پله، زدن دکمهٔ تندتر باید همان بالاترین پله را بدهد و برای پایینترین پله هم به همین شکل؛ یعنی از دو سر فهرست بیرون نمیزند و خطا هم نمیدهد.
- **تابع `scaledDelta(dt, speed)`:** فاصلهٔ زمانی یک فریم را در سرعت پخش ضرب میکند تا معلوم شود نشانگر چقدر جلو برود. سرعت را **پیش از ضرب** مهار میکند، پس عدد بیمعنایی مثل `9` هم نتیجه را از سقف مجاز فراتر نمیبرد.
```js
clampSpeed(0.1) // 0.25
clampSpeed(5) // 2
clampSpeed("x") // 1 (a non-numeric input falls back to normal speed)
snap(0.6) // 0.5
snap(1.7) // 1.5
indexOf(1) // 2
stepSpeed(1, 1) // 1.5
stepSpeed(2, 1) // 2 (already at the top)
stepSpeed(0.25, -1) // 0.25 (already at the bottom)
scaledDelta(2, 1.5) // 3
scaledDelta(2, 9) // 4 (speed clamped to 2 first)
```
> تفاوت مهار و پلهای کردن را در خطوط اول ببینید: `0.1` و `5` بیرون بازهاند و به دو سر میچسبند، ولی `0.6` که داخل بازه است تنها با `snap` به `0.5` میرسد. دو خط `stepSpeed` رفتار اشباع را نشان میدهند؛ فهرست دور نمیزند. در مثال آخر، سرعت ابتدا تا سقف مجاز محدود میشود: برای سرعت `9` نتیجه `18` نیست، چون سرعت اول تا سقف `2` مهار میشود و بعد ضرب انجام میگیرد.
</details>
<details class="olive">
<summary>**پیادهسازی فایل `clock.js` (ساعت پخش)**</summary>
کار ساعت فقط یک چیز است: اندازهگیری فاصلهٔ زمانی از تیک قبلی! برای اینکه تستها قطعی بمانند، منبع زمان از بیرون تزریق میشود:
```js
export function createClock(onTick, now = () => performance.now()) { /* ... */ }
```
> پارامتر `onTick` تابعی است که در هر تیک صدا زده میشود و پارامتر دوم منبع زمان است. تزریق منبع زمان عمدی است: در تست میشود یک تابع ساختگی داد و زمان را دستی جلو برد، بدون اینکه واقعاً منتظر بمانیم. نکتهٔ اصلی ماژول هم همینجاست؛ هر تیک فاصله از **تیک قبلی** را میدهد نه از لحظهٔ شروع، پس با تیکهایی در میلیثانیهٔ `500` و `800`، خروجیها بهترتیب `0.5` و `0.3` ثانیهاند.
تابع `createClock(onTick, now)` یک **شیء ساعت** برمیگرداند، نه عدد و نه رشته. پارامتر دومش اختیاری است و اگر ندهید، از زمان واقعی مرورگر استفاده میشود. شیء برگشتی این متدها را دارد:
+ `start()` و `stop()` ساعت را روشن و خاموش میکنند و `toggle()` وضعیت را برعکس میکند.
+ `isPlaying()` وضعیت فعلی ساعت را بهصورت بولی برمیگرداند.
+ `tick()` که فاصلهٔ زمانی از تیک قبلی را **بر حسب ثانیه** حساب میکند، به `onTick` میدهد و همان را هم برمیگرداند.
+ **نکته:** وقتی ساعت متوقف است، `tick()` نباید `onTick` را صدا بزند.
</details>
<details class="pink">
<summary>**پیادهسازی فایل `loop.js` (تکرار یک بازه)**</summary>
لوپ همان قابلیتی است که در نرمافزارهای تدوین به آن `A/B loop` میگویند: کاربر دو نقطه روی خط زمان میگذارد و پخشکننده همان بازه را بیوقفه تکرار میکند. لوپ دو حالت دارد و همهٔ توابع باید هر دو را درست مدیریت کنند: **نیمهکاره** یعنی فقط یکی از دو نقطه گذاشته شده و لوپ هیچ اثری روی پخش ندارد و **فعال** یعنی هر دو نقطه هست و بازهٔ واقعی میسازند.
|  |
| :-: |
| بازهٔ تکرار میان دو نقطهٔ `A` و `B` |
- **تابع `createLoop()`:** حلقهٔ خالی میسازد، یعنی شیئی که هر دو نقطهاش `null` است. مقدار `null` اینجا معنادار است و با `0` فرق دارد: `null` یعنی «کاربر هنوز این نقطه را نگذاشته»، ولی `0` یعنی «نقطه روی ثانیهٔ صفر گذاشته شده».
- **توابع `setA(loop, time)` و `setB(loop, time)`:** یکی از دو نقطه را روی زمان دادهشده میگذارند و حلقهٔ تازه برمیگردانند. نکتهاش این است که کاربر ممکن است نقطهٔ پایان را جلوتر از نقطهٔ شروع بگذارد؛ در آن حالت این توابع خودشان دو نقطه را مرتب میکنند تا شرط «`a` همیشه از `b` کوچکتر است» نشکند.
- **تابع `normalize(loop)`:** همان مرتبسازی را روی یک حلقهٔ آماده انجام میدهد: اگر دو نقطه جابهجا باشند جایشان را عوض میکند، وگرنه حلقه را دستنخورده برمیگرداند. حلقهای که یکی از نقطههایش `null` است چیزی برای مرتب کردن ندارد.
- **تابع `isActive(loop)`:** میگوید حلقه واقعاً فعال است یا نه و مقدار بولی برمیگرداند. فعال بودن دو شرط دارد: هر دو نقطه گذاشته شده باشند و بازه طول واقعی داشته باشد یعنی `b` اکیداً از `a` بزرگتر باشد. حلقهای که هر دو نقطهاش روی یک زمان است فعال حساب نمیشود.
- **تابع `loopLength(loop)`:** طول بازهٔ تکرار را برمیگرداند. برای حلقهای که فعال نیست، مقدار `0` است؛ یعنی این تابع هیچوقت `NaN` یا عدد منفی نمیدهد.
- **تابع `contains(loop, time)`:** میگوید این لحظه داخل بازهٔ تکرار میافتد یا نه. برخلاف `clipAt` در `track.js`، اینجا **هر دو** مرز داخل بازه حساب میشوند. برای حلقهٔ غیرفعال همیشه `false` برمیگردد.
- **تابع `wrap(loop, time)`:** این تابع منطق اصلی تکرار بازه را پیاده میکند. اگر زمان دادهشده از انتهای بازه گذشته باشد، مقدار متناظرش را از ابتدای بازه برمیگرداند؛ یعنی همانقدر که از `b` جلو زده، از `a` جلو میرود. اگر لوپ فعال نباشد یا زمان هنوز به انتهای بازه نرسیده باشد، همان زمان بدون تغییر برمیگردد. برای پرشهای خیلی بزرگ هم باید کار کند و نتیجه همیشه داخل بازه بماند.
- **تابع `clearLoop()`:** حلقه را پاک میکند و همان حلقهٔ خالی اولیه را برمیگرداند.
```js
setB(setA(createLoop(), 10), 4) // { a: 4, b: 10 } (auto-ordered)
loopLength({ a: 5, b: 12 }) // 7
contains({ a: 5, b: 10 }, 7) // true
wrap({ a: 5, b: 10 }, 11) // 6
wrap({ a: 5, b: 10 }, 7) // 7
wrap(createLoop(), 42) // 42 (no loop, no change)
```
> خط اول نشان میدهد نقطههای جابهجا خودشان مرتب میشوند: `A` روی `10` و `B` روی `4` گذاشته شده، ولی خروجی `a` برابر `4` و `b` برابر `10` است. سه خط آخر سه حالت `wrap` را روشن میکنند: زمان `11` یک ثانیه از انتهای بازهٔ `5..10` گذشته پس به `6` برمیگردد، زمان `7` داخل بازه است و دستنخورده میماند و در خط آخر لوپ فعال نیست پس زمان `42` بدون تغییر عبور میکند.
</details>
<details class="brown">
<summary>**پیادهسازی فایل `filmstrip.js` (نوار `filmstrip`)**</summary>
نوار `filmstrip` همان نوار باریکی است که زیر خط زمان کل فیلم را در چند قاب کوچک خلاصه میکند. اینجا تصویری در کار نیست؛ خط زمان را به چند بخش مساوی تقسیم میکنید و برای هر بخش میگویید در آن لحظه کدام کلیپ پخش میشود، تا رابط کاربری بتواند سلولها را رنگ کند و برچسب بزند.
|  |
| :-: |
| سلولهای پیشنمایش که کل فیلم را خلاصه میکنند |
قاعدهٔ نمونهبرداری این است که از **وسط** هر بخش نمونه بردارید، نه از ابتدای آن؛ برای فیلم ۴۰ ثانیهای و هشت سلول، نمونهها روی ۲.۵ و ۷.۵ و ۱۲.۵ و... میافتند. نمونهٔ ابتدای بخش دقیقاً روی مرز کلیپها میافتد و سلول را نمایندهٔ کلیپ اشتباهی میکند.
- **تابع `buildCells(track, count)`:** به تعداد `count` سلول میسازد. هر سلول شش فیلد دارد: `index` جایگاهش در نوار، `time` زمان نمونهبرداری، `fraction` همان زمان بهصورت نسبتی بین `0` و `1` و `clipId` و `kind` و `label` که از کلیپ فعال در آن لحظه برداشته میشوند. اگر آن لحظه هیچ کلیپی فعال نباشد، نزدیکترین کلیپِ شروعشده جایش را میگیرد؛ و اگر `track` اصلاً کلیپی نداشته باشد، `kind` برابر `"empty"` و `label` رشتهٔ خالی میشود. تعداد کمتر از یک هم پذیرفته نمیشود و نوار همیشه دستکم یک سلول دارد.
- **تابع `cellAtFraction(count, fraction)`:** نسبتی بین `0` و `1` میگیرد و میگوید نشانگر ماوس روی کدام سلول نوار ایستاده است. خروجی همیشه یک اندیس معتبر بین `0` و `count - 1` است، پس نسبتهای بیرون بازه به نزدیکترین سلول دو سر نوار میرسند و نسبت `1` هم به آخرین سلول میرسد، نه به سلولی که وجود ندارد.
- **تابع `previewAt(track, fraction)`:** اطلاعاتی را برمیگرداند که هنگام قرار گرفتن ماوس روی نوار نمایش داده میشود. نسبتی بین `0` و `1` میگیرد و شیئی با چهار فیلد `time` و `fraction` و `label` و `kind` برمیگرداند: زمان متناظر آن نقطه و مشخصات کلیپی که آن لحظه پخش میشود. تفاوتش با `buildCells` این است که آنجا نمونهها روی وسط بخشهای ثابت میافتند، ولی اینجا زمان دقیقاً از روی نسبت دادهشده حساب میشود.
```js
const cells = buildCells(createTrack(SPECS), 8);
cells[0].time // 2.5
cells[7].time // 37.5
cells[0].kind // "start"
cells[6].label // "C"
cellAtFraction(8, 0.5) // 4
cellAtFraction(8, 0.99) // 7
cellAtFraction(8, -0.4) // 0 (clamped)
cellAtFraction(8, 1.5) // 7 (clamped)
```
> نمونهبرداری از وسط بخشها را در دو خط اول ببینید: با طول `40` و هشت سلول، اولین سلول روی `2.5` میافتد و آخرین روی `37.5`، نه روی `0` و `35`. چهار خط آخر هم مهار `cellAtFraction` را نشان میدهند؛ نسبت `0.99` به آخرین سلول میرسد و نسبتهای بیرون بازه به دو سر نوار.
</details>
<details class="grey">
<summary>**پیادهسازی `app.js` (وصل کردن ماژولها به صفحه)**</summary>
**تمام عنصرهای زیر از قبل در `index.html` هستند و همگی `data-testid` خودشان را دارند.** در `app.js` هم شیء `el` از قبل به هر کدام اشاره میکند. کار شما ساختن هیچ عنصری نیست؛ کار شما این است که **محتوا و وضعیتشان را از روی مدل پخشکننده بهروز کنید.**
فایل `app.js` باید شیء `window.player` را بسازد. سیستم داوری از طریق همین شیء با پخشکننده کار میکند، پس این متدها باید دقیقاً با همین نامها وجود داشته باشند:
+ `seek(time)` و `seekFraction(f)` نشانگر پخش را جابهجا میکنند؛ `getTime()` زمان فعلی و `getDuration()` طول کل را برمیگردانند.
+ `play()` و `pause()` پخش را روشن و خاموش میکنند و `isPlaying()` وضعیت فعلی را بهصورت بولی میدهد.
+ `advanceBy(dt)` پخش را بهاندازهٔ `dt` ثانیه جلو میبرد.
+ `setSpeed(value)` سرعت را میگذارد و `getSpeed()` سرعت فعلی را برمیگرداند.
+ `nextChapter()` و `prevChapter()` نشانگر را به فصل بعدی یا قبلی میبرند.
+ `trimSetIn()` و `trimSetOut()` سرِ ابتدا یا انتهای بازهٔ برش را روی **زمان فعلی** میگذارند.
+ `loopSetA()` و `loopSetB()` نقطهٔ ابتدا یا انتهای حلقه را روی زمان فعلی میگذارند و `getLoop()` حلقهٔ فعلی را برمیگرداند.
+ `activeClip()` که کلیپ فعال فعلی را میدهد.
+ `state` که باید `state.chapters` و `state.filmstrip` را داشته باشد.
|  |
| :-: |
| چیدمان کامل پخشکننده با همهٔ کنترلها |
**آنچه باید در هر بار رندر بهروز شود:**
| **عنصر** | **کاری که `app.js` میکند** |
| :-: | :-: |
| `stage-clip` و `stage-kind` | نام و نوع کلیپ فعال را مینویسد |
| `current-time` و `total-time` | زمان فعلی و طول کل را با `formatTime` مینویسد |
| `playhead` | مقدار `style.left` را از نسبت زمان میگذارد |
| `progress` | مقدار `style.width` را از همان نسبت میگذارد؛ در زمان صفر باید صفر و در انتها بیش از ۹۰ درصد باشد |
| `timeline` | مقدار `aria-valuenow` را با **دو رقم اعشار** مینویسد، مثل `12.00` |
| `minimap-window` | مقدار `style.left` را از نسبت زمان میگذارد |
| `play-pause` | مقدار `aria-pressed` را `"true"`/`"false"` میکند و هنگام پخش متنش شامل `Pause` میشود |
| `speed-select` | مقدار `value` را با سرعت فعلی مدل همگام نگه میدارد |
| `loop-region` | مقدار `style.width` و `data-active` را میگذارد؛ `data-active` در شروع `"false"` است و بعد از تعیین هر دو نقطه `"true"` |
| `trim-region` و `trim-in-value` و `trim-out-value` | بازهٔ کلیپ فعال را نشان میدهند؛ عرض `trim-region` از همان بارگذاری اول باید غیرصفر باشد |
**آنچه باید پیادهسازی شود:** چهار ظرف `ticks` و `markers` و `chapter-list` و `filmstrip` در `index.html` **خالی** هستند و `app.js` باید پرشان کند:
+ `ticks` بیش از یک خطکش میگیرد؛ کاملاً تزئینی است.
+ `markers` برای هر فصل یک `marker-<id>` میگیرد؛ هرکدام `aria-label` دارند، با `style.left` جایگذاری میشوند و با کلیک، پخش به آن لحظه میپرد.
+ `chapter-list` برای هر فصل یک `chapter-item-<id>` میگیرد؛ کلیک روی هر مورد نشانگر را به زمان همان فصل میبرد و فصل فعال `aria-current="true"` میگیرد.
+ `filmstrip` برای هر سلول یک `film-cell-<index>` میگیرد و کلیک روی هر سلول نشانگر را به زمان همان سلول میبرد.
**پیشنمایش:** با حرکت موس روی خط زمان، `preview-flag` باید `data-show="true"` بگیرد و `preview-time` زمان آن نقطه را نشان بدهد. با خارج شدن موس دوباره `"false"` میشود.
**کیبورد:**
+ روی خط زمان: `ArrowRight` و `ArrowLeft` جلو و عقب، `Home` به ابتدا، `End` به انتها.
+ در سطح `document`: کلید `Space` پخش و توقف را انجام میدهد، `.` و `,` گام کوچک جلو و عقب میروند و `[` و `]` نقطهٔ شروع و پایان برش را تعیین میکنند. این کلیدها فقط وقتی کار میکنند که تمرکز روی یک فیلد متنی یا عنصر قابل ویرایش نباشد.
+ **نکته:** کلید `Space` را با `event.code === "Space"` تشخیص بدهید، نه با `event.key`. سیستم داوری این رویداد را فقط با `code` میفرستد و `event.key` در `handler` شما `undefined` خواهد بود.
**نکته:** زمان فعلی، سرعت پخش و بازهٔ برش هر کلیپ باید در `localStorage` زیر کلید `road-highlight-replay` ذخیره شوند و بعد از رفرش برگردند. بازهٔ تکرار (`loop`) ذخیره نمیشود و با هر بار باز شدن صفحه خالی است.
+ **نکته:** اگر مقدار ذخیرهشده خراب باشد، صفحه باید سالم بالا بیاید و از صفر شروع کند. توابع `loadState` و `saveState` از قبل در `app.js` نوشته شدهاند و همین حالت را مدیریت میکنند.
</details>
# **آنچه باید آپلود کنید**
- **توجه:** فایلی که آپلود میکنید باید فرمت زیپ داشته باشد و ساختارش اینطور باشد:
```plaintext
├── modules/
│ ├── app.js
│ ├── chapters.js
│ ├── clock.js
│ ├── data.js
│ ├── filmstrip.js
│ ├── format.js
│ ├── loop.js
│ ├── playhead.js
│ ├── speed.js
│ ├── track.js
│ └── trim.js
├── index.html
└── styles.css
```
+ **توجه:** درخت بالا دقیقاً همان چیزی است که باید در فایل زیپ آپلود کنید. پوشهٔ `modules/` و دو فایل `index.html` و `styles.css` باید در ریشهٔ فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای مثل `answer/` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل جدیدی نسازید!** سیستم داوری کوئرا فقط فایلهای بالا را برمیدارد، پس اگر ماژول جدیدی بسازید و از جایی `import`ش کنید، آن ماژول در داوری وجود نخواهد داشت و بارگذاری کل صفحه شکست میخورد. کد کمکی را داخل همان فایلهای موجود بنویسید.
- **توجه:** تمام متنهای داخل صفحه **انگلیسی** هستند.
- **توجه:** پخش نباید از انتهای خط زمان جلوتر برود و وقتی به انتها رسید، باید خودش متوقف شود. اگر کاربر دوباره از روی انتها `play` بزند، پخش از صفر شروع میشود.
- **توجه:** داوری به رنگ و فونت و پیکسل کاری ندارد. مقدار بازگشتی توابع، رفتار صفحه و `data-testid`ها بررسی میشوند.
ارسال پاسخ برای این سؤال
در حال حاضر شما دسترسی ندارید.