mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-17 18:11:20 -08:00
refactor(core): CORE-19 type capture and preserve progress lifetimes
This commit is contained in:
1 parent
98a1800032
commit
ccf77c4ed2
49 files changed
+31570
-681
No files matched your search
@@ -57,22 +57,26 @@ test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Esc
|
||||
await page.evaluate(() => {
|
||||
window.art.isFocus = true
|
||||
window.nativeHotkeys = []
|
||||
window.nativeHotkeySeeks = []
|
||||
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
|
||||
window.art.on('seek', (time, requested) => window.nativeHotkeySeeks.push(requested))
|
||||
})
|
||||
await page.keyboard.press('Space')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
|
||||
await page.keyboard.press('Space')
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.currentTime = 1
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
|
||||
const start = await page.evaluate(() => ({ time: window.art.currentTime, step: window.Artplayer.SEEK_STEP }))
|
||||
await page.keyboard.press('ArrowRight')
|
||||
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
|
||||
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(start.time + start.step, 1)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(start.time)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
|
||||
const forward = await page.evaluate(() => window.art.currentTime)
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
|
||||
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(forward - start.step, 1)
|
||||
expect(await page.evaluate(() => window.nativeHotkeySeeks.length)).toBe(2)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(forward)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
|
||||
const volume = await page.evaluate(() => {
|
||||
window.art.volume = 0.5
|
||||
return window.art.volume
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600">
|
||||
<rect x="0" y="0" width="100" height="60" fill="hsl(0 70% 45%)"/>
|
||||
<text x="35" y="38" fill="white" font-size="25">0</text>
|
||||
<rect x="100" y="0" width="100" height="60" fill="hsl(137.508 70% 45%)"/>
|
||||
<text x="135" y="38" fill="white" font-size="25">1</text>
|
||||
<rect x="200" y="0" width="100" height="60" fill="hsl(275.016 70% 45%)"/>
|
||||
<text x="235" y="38" fill="white" font-size="25">2</text>
|
||||
<rect x="300" y="0" width="100" height="60" fill="hsl(52.524 70% 45%)"/>
|
||||
<text x="335" y="38" fill="white" font-size="25">3</text>
|
||||
<rect x="400" y="0" width="100" height="60" fill="hsl(190.03200000000004 70% 45%)"/>
|
||||
<text x="435" y="38" fill="white" font-size="25">4</text>
|
||||
<rect x="500" y="0" width="100" height="60" fill="hsl(327.5400000000001 70% 45%)"/>
|
||||
<text x="535" y="38" fill="white" font-size="25">5</text>
|
||||
<rect x="600" y="0" width="100" height="60" fill="hsl(105.048 70% 45%)"/>
|
||||
<text x="635" y="38" fill="white" font-size="25">6</text>
|
||||
<rect x="700" y="0" width="100" height="60" fill="hsl(242.55600000000004 70% 45%)"/>
|
||||
<text x="735" y="38" fill="white" font-size="25">7</text>
|
||||
<rect x="800" y="0" width="100" height="60" fill="hsl(20.064000000000078 70% 45%)"/>
|
||||
<text x="835" y="38" fill="white" font-size="25">8</text>
|
||||
<rect x="900" y="0" width="100" height="60" fill="hsl(157.57200000000012 70% 45%)"/>
|
||||
<text x="935" y="38" fill="white" font-size="25">9</text>
|
||||
<rect x="0" y="60" width="100" height="60" fill="hsl(295.08000000000015 70% 45%)"/>
|
||||
<text x="35" y="98" fill="white" font-size="25">10</text>
|
||||
<rect x="100" y="60" width="100" height="60" fill="hsl(72.58800000000019 70% 45%)"/>
|
||||
<text x="135" y="98" fill="white" font-size="25">11</text>
|
||||
<rect x="200" y="60" width="100" height="60" fill="hsl(210.096 70% 45%)"/>
|
||||
<text x="235" y="98" fill="white" font-size="25">12</text>
|
||||
<rect x="300" y="60" width="100" height="60" fill="hsl(347.60400000000004 70% 45%)"/>
|
||||
<text x="335" y="98" fill="white" font-size="25">13</text>
|
||||
<rect x="400" y="60" width="100" height="60" fill="hsl(125.11200000000008 70% 45%)"/>
|
||||
<text x="435" y="98" fill="white" font-size="25">14</text>
|
||||
<rect x="500" y="60" width="100" height="60" fill="hsl(262.62000000000035 70% 45%)"/>
|
||||
<text x="535" y="98" fill="white" font-size="25">15</text>
|
||||
<rect x="600" y="60" width="100" height="60" fill="hsl(40.128000000000156 70% 45%)"/>
|
||||
<text x="635" y="98" fill="white" font-size="25">16</text>
|
||||
<rect x="700" y="60" width="100" height="60" fill="hsl(177.63599999999997 70% 45%)"/>
|
||||
<text x="735" y="98" fill="white" font-size="25">17</text>
|
||||
<rect x="800" y="60" width="100" height="60" fill="hsl(315.14400000000023 70% 45%)"/>
|
||||
<text x="835" y="98" fill="white" font-size="25">18</text>
|
||||
<rect x="900" y="60" width="100" height="60" fill="hsl(92.65200000000004 70% 45%)"/>
|
||||
<text x="935" y="98" fill="white" font-size="25">19</text>
|
||||
<rect x="0" y="120" width="100" height="60" fill="hsl(230.1600000000003 70% 45%)"/>
|
||||
<text x="35" y="158" fill="white" font-size="25">20</text>
|
||||
<rect x="100" y="120" width="100" height="60" fill="hsl(7.66800000000012 70% 45%)"/>
|
||||
<text x="135" y="158" fill="white" font-size="25">21</text>
|
||||
<rect x="200" y="120" width="100" height="60" fill="hsl(145.17600000000039 70% 45%)"/>
|
||||
<text x="235" y="158" fill="white" font-size="25">22</text>
|
||||
<rect x="300" y="120" width="100" height="60" fill="hsl(282.6840000000002 70% 45%)"/>
|
||||
<text x="335" y="158" fill="white" font-size="25">23</text>
|
||||
<rect x="400" y="120" width="100" height="60" fill="hsl(60.19200000000001 70% 45%)"/>
|
||||
<text x="435" y="158" fill="white" font-size="25">24</text>
|
||||
<rect x="500" y="120" width="100" height="60" fill="hsl(197.70000000000027 70% 45%)"/>
|
||||
<text x="535" y="158" fill="white" font-size="25">25</text>
|
||||
<rect x="600" y="120" width="100" height="60" fill="hsl(335.2080000000001 70% 45%)"/>
|
||||
<text x="635" y="158" fill="white" font-size="25">26</text>
|
||||
<rect x="700" y="120" width="100" height="60" fill="hsl(112.71600000000035 70% 45%)"/>
|
||||
<text x="735" y="158" fill="white" font-size="25">27</text>
|
||||
<rect x="800" y="120" width="100" height="60" fill="hsl(250.22400000000016 70% 45%)"/>
|
||||
<text x="835" y="158" fill="white" font-size="25">28</text>
|
||||
<rect x="900" y="120" width="100" height="60" fill="hsl(27.732000000000426 70% 45%)"/>
|
||||
<text x="935" y="158" fill="white" font-size="25">29</text>
|
||||
<rect x="0" y="180" width="100" height="60" fill="hsl(165.2400000000007 70% 45%)"/>
|
||||
<text x="35" y="218" fill="white" font-size="25">30</text>
|
||||
<rect x="100" y="180" width="100" height="60" fill="hsl(302.7480000000005 70% 45%)"/>
|
||||
<text x="135" y="218" fill="white" font-size="25">31</text>
|
||||
<rect x="200" y="180" width="100" height="60" fill="hsl(80.25600000000031 70% 45%)"/>
|
||||
<text x="235" y="218" fill="white" font-size="25">32</text>
|
||||
<rect x="300" y="180" width="100" height="60" fill="hsl(217.76400000000012 70% 45%)"/>
|
||||
<text x="335" y="218" fill="white" font-size="25">33</text>
|
||||
<rect x="400" y="180" width="100" height="60" fill="hsl(355.27199999999993 70% 45%)"/>
|
||||
<text x="435" y="218" fill="white" font-size="25">34</text>
|
||||
<rect x="500" y="180" width="100" height="60" fill="hsl(132.78000000000065 70% 45%)"/>
|
||||
<text x="535" y="218" fill="white" font-size="25">35</text>
|
||||
<rect x="600" y="180" width="100" height="60" fill="hsl(270.28800000000047 70% 45%)"/>
|
||||
<text x="635" y="218" fill="white" font-size="25">36</text>
|
||||
<rect x="700" y="180" width="100" height="60" fill="hsl(47.79600000000028 70% 45%)"/>
|
||||
<text x="735" y="218" fill="white" font-size="25">37</text>
|
||||
<rect x="800" y="180" width="100" height="60" fill="hsl(185.3040000000001 70% 45%)"/>
|
||||
<text x="835" y="218" fill="white" font-size="25">38</text>
|
||||
<rect x="900" y="180" width="100" height="60" fill="hsl(322.8120000000008 70% 45%)"/>
|
||||
<text x="935" y="218" fill="white" font-size="25">39</text>
|
||||
<rect x="0" y="240" width="100" height="60" fill="hsl(100.32000000000062 70% 45%)"/>
|
||||
<text x="35" y="278" fill="white" font-size="25">40</text>
|
||||
<rect x="100" y="240" width="100" height="60" fill="hsl(237.82800000000043 70% 45%)"/>
|
||||
<text x="135" y="278" fill="white" font-size="25">41</text>
|
||||
<rect x="200" y="240" width="100" height="60" fill="hsl(15.33600000000024 70% 45%)"/>
|
||||
<text x="235" y="278" fill="white" font-size="25">42</text>
|
||||
<rect x="300" y="240" width="100" height="60" fill="hsl(152.84400000000005 70% 45%)"/>
|
||||
<text x="335" y="278" fill="white" font-size="25">43</text>
|
||||
<rect x="400" y="240" width="100" height="60" fill="hsl(290.35200000000077 70% 45%)"/>
|
||||
<text x="435" y="278" fill="white" font-size="25">44</text>
|
||||
<rect x="500" y="240" width="100" height="60" fill="hsl(67.86000000000058 70% 45%)"/>
|
||||
<text x="535" y="278" fill="white" font-size="25">45</text>
|
||||
<rect x="600" y="240" width="100" height="60" fill="hsl(205.3680000000004 70% 45%)"/>
|
||||
<text x="635" y="278" fill="white" font-size="25">46</text>
|
||||
<rect x="700" y="240" width="100" height="60" fill="hsl(342.8760000000002 70% 45%)"/>
|
||||
<text x="735" y="278" fill="white" font-size="25">47</text>
|
||||
<rect x="800" y="240" width="100" height="60" fill="hsl(120.38400000000001 70% 45%)"/>
|
||||
<text x="835" y="278" fill="white" font-size="25">48</text>
|
||||
<rect x="900" y="240" width="100" height="60" fill="hsl(257.89200000000073 70% 45%)"/>
|
||||
<text x="935" y="278" fill="white" font-size="25">49</text>
|
||||
<rect x="0" y="300" width="100" height="60" fill="hsl(35.400000000000546 70% 45%)"/>
|
||||
<text x="35" y="338" fill="white" font-size="25">50</text>
|
||||
<rect x="100" y="300" width="100" height="60" fill="hsl(172.90800000000036 70% 45%)"/>
|
||||
<text x="135" y="338" fill="white" font-size="25">51</text>
|
||||
<rect x="200" y="300" width="100" height="60" fill="hsl(310.41600000000017 70% 45%)"/>
|
||||
<text x="235" y="338" fill="white" font-size="25">52</text>
|
||||
<rect x="300" y="300" width="100" height="60" fill="hsl(87.92400000000089 70% 45%)"/>
|
||||
<text x="335" y="338" fill="white" font-size="25">53</text>
|
||||
<rect x="400" y="300" width="100" height="60" fill="hsl(225.4320000000007 70% 45%)"/>
|
||||
<text x="435" y="338" fill="white" font-size="25">54</text>
|
||||
<rect x="500" y="300" width="100" height="60" fill="hsl(2.9400000000005093 70% 45%)"/>
|
||||
<text x="535" y="338" fill="white" font-size="25">55</text>
|
||||
<rect x="600" y="300" width="100" height="60" fill="hsl(140.44800000000032 70% 45%)"/>
|
||||
<text x="635" y="338" fill="white" font-size="25">56</text>
|
||||
<rect x="700" y="300" width="100" height="60" fill="hsl(277.95600000000013 70% 45%)"/>
|
||||
<text x="735" y="338" fill="white" font-size="25">57</text>
|
||||
<rect x="800" y="300" width="100" height="60" fill="hsl(55.46400000000085 70% 45%)"/>
|
||||
<text x="835" y="338" fill="white" font-size="25">58</text>
|
||||
<rect x="900" y="300" width="100" height="60" fill="hsl(192.97200000000066 70% 45%)"/>
|
||||
<text x="935" y="338" fill="white" font-size="25">59</text>
|
||||
<rect x="0" y="360" width="100" height="60" fill="hsl(330.4800000000014 70% 45%)"/>
|
||||
<text x="35" y="398" fill="white" font-size="25">60</text>
|
||||
<rect x="100" y="360" width="100" height="60" fill="hsl(107.9880000000012 70% 45%)"/>
|
||||
<text x="135" y="398" fill="white" font-size="25">61</text>
|
||||
<rect x="200" y="360" width="100" height="60" fill="hsl(245.496000000001 70% 45%)"/>
|
||||
<text x="235" y="398" fill="white" font-size="25">62</text>
|
||||
<rect x="300" y="360" width="100" height="60" fill="hsl(23.004000000000815 70% 45%)"/>
|
||||
<text x="335" y="398" fill="white" font-size="25">63</text>
|
||||
<rect x="400" y="360" width="100" height="60" fill="hsl(160.51200000000063 70% 45%)"/>
|
||||
<text x="435" y="398" fill="white" font-size="25">64</text>
|
||||
<rect x="500" y="360" width="100" height="60" fill="hsl(298.02000000000044 70% 45%)"/>
|
||||
<text x="535" y="398" fill="white" font-size="25">65</text>
|
||||
<rect x="600" y="360" width="100" height="60" fill="hsl(75.52800000000025 70% 45%)"/>
|
||||
<text x="635" y="398" fill="white" font-size="25">66</text>
|
||||
<rect x="700" y="360" width="100" height="60" fill="hsl(213.03600000000006 70% 45%)"/>
|
||||
<text x="735" y="398" fill="white" font-size="25">67</text>
|
||||
<rect x="800" y="360" width="100" height="60" fill="hsl(350.54399999999987 70% 45%)"/>
|
||||
<text x="835" y="398" fill="white" font-size="25">68</text>
|
||||
<rect x="900" y="360" width="100" height="60" fill="hsl(128.0520000000015 70% 45%)"/>
|
||||
<text x="935" y="398" fill="white" font-size="25">69</text>
|
||||
<rect x="0" y="420" width="100" height="60" fill="hsl(265.5600000000013 70% 45%)"/>
|
||||
<text x="35" y="458" fill="white" font-size="25">70</text>
|
||||
<rect x="100" y="420" width="100" height="60" fill="hsl(43.06800000000112 70% 45%)"/>
|
||||
<text x="135" y="458" fill="white" font-size="25">71</text>
|
||||
<rect x="200" y="420" width="100" height="60" fill="hsl(180.57600000000093 70% 45%)"/>
|
||||
<text x="235" y="458" fill="white" font-size="25">72</text>
|
||||
<rect x="300" y="420" width="100" height="60" fill="hsl(318.08400000000074 70% 45%)"/>
|
||||
<text x="335" y="458" fill="white" font-size="25">73</text>
|
||||
<rect x="400" y="420" width="100" height="60" fill="hsl(95.59200000000055 70% 45%)"/>
|
||||
<text x="435" y="458" fill="white" font-size="25">74</text>
|
||||
<rect x="500" y="420" width="100" height="60" fill="hsl(233.10000000000036 70% 45%)"/>
|
||||
<text x="535" y="458" fill="white" font-size="25">75</text>
|
||||
<rect x="600" y="420" width="100" height="60" fill="hsl(10.608000000000175 70% 45%)"/>
|
||||
<text x="635" y="458" fill="white" font-size="25">76</text>
|
||||
<rect x="700" y="420" width="100" height="60" fill="hsl(148.11599999999999 70% 45%)"/>
|
||||
<text x="735" y="458" fill="white" font-size="25">77</text>
|
||||
<rect x="800" y="420" width="100" height="60" fill="hsl(285.6240000000016 70% 45%)"/>
|
||||
<text x="835" y="458" fill="white" font-size="25">78</text>
|
||||
<rect x="900" y="420" width="100" height="60" fill="hsl(63.132000000001426 70% 45%)"/>
|
||||
<text x="935" y="458" fill="white" font-size="25">79</text>
|
||||
<rect x="0" y="480" width="100" height="60" fill="hsl(200.64000000000124 70% 45%)"/>
|
||||
<text x="35" y="518" fill="white" font-size="25">80</text>
|
||||
<rect x="100" y="480" width="100" height="60" fill="hsl(338.14800000000105 70% 45%)"/>
|
||||
<text x="135" y="518" fill="white" font-size="25">81</text>
|
||||
<rect x="200" y="480" width="100" height="60" fill="hsl(115.65600000000086 70% 45%)"/>
|
||||
<text x="235" y="518" fill="white" font-size="25">82</text>
|
||||
<rect x="300" y="480" width="100" height="60" fill="hsl(253.16400000000067 70% 45%)"/>
|
||||
<text x="335" y="518" fill="white" font-size="25">83</text>
|
||||
<rect x="400" y="480" width="100" height="60" fill="hsl(30.67200000000048 70% 45%)"/>
|
||||
<text x="435" y="518" fill="white" font-size="25">84</text>
|
||||
<rect x="500" y="480" width="100" height="60" fill="hsl(168.1800000000003 70% 45%)"/>
|
||||
<text x="535" y="518" fill="white" font-size="25">85</text>
|
||||
<rect x="600" y="480" width="100" height="60" fill="hsl(305.6880000000001 70% 45%)"/>
|
||||
<text x="635" y="518" fill="white" font-size="25">86</text>
|
||||
<rect x="700" y="480" width="100" height="60" fill="hsl(83.19600000000173 70% 45%)"/>
|
||||
<text x="735" y="518" fill="white" font-size="25">87</text>
|
||||
<rect x="800" y="480" width="100" height="60" fill="hsl(220.70400000000154 70% 45%)"/>
|
||||
<text x="835" y="518" fill="white" font-size="25">88</text>
|
||||
<rect x="900" y="480" width="100" height="60" fill="hsl(358.21200000000135 70% 45%)"/>
|
||||
<text x="935" y="518" fill="white" font-size="25">89</text>
|
||||
<rect x="0" y="540" width="100" height="60" fill="hsl(135.72000000000116 70% 45%)"/>
|
||||
<text x="35" y="578" fill="white" font-size="25">90</text>
|
||||
<rect x="100" y="540" width="100" height="60" fill="hsl(273.228000000001 70% 45%)"/>
|
||||
<text x="135" y="578" fill="white" font-size="25">91</text>
|
||||
<rect x="200" y="540" width="100" height="60" fill="hsl(50.736000000000786 70% 45%)"/>
|
||||
<text x="235" y="578" fill="white" font-size="25">92</text>
|
||||
<rect x="300" y="540" width="100" height="60" fill="hsl(188.2440000000006 70% 45%)"/>
|
||||
<text x="335" y="578" fill="white" font-size="25">93</text>
|
||||
<rect x="400" y="540" width="100" height="60" fill="hsl(325.7520000000004 70% 45%)"/>
|
||||
<text x="435" y="578" fill="white" font-size="25">94</text>
|
||||
<rect x="500" y="540" width="100" height="60" fill="hsl(103.26000000000022 70% 45%)"/>
|
||||
<text x="535" y="578" fill="white" font-size="25">95</text>
|
||||
<rect x="600" y="540" width="100" height="60" fill="hsl(240.76800000000003 70% 45%)"/>
|
||||
<text x="635" y="578" fill="white" font-size="25">96</text>
|
||||
<rect x="700" y="540" width="100" height="60" fill="hsl(18.27600000000166 70% 45%)"/>
|
||||
<text x="735" y="578" fill="white" font-size="25">97</text>
|
||||
<rect x="800" y="540" width="100" height="60" fill="hsl(155.78400000000147 70% 45%)"/>
|
||||
<text x="835" y="578" fill="white" font-size="25">98</text>
|
||||
<rect x="900" y="540" width="100" height="60" fill="hsl(293.2920000000013 70% 45%)"/>
|
||||
<text x="935" y="578" fill="white" font-size="25">99</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,234 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const mode of ['remove', 'destroy', 'source']) {
|
||||
test(`${core}: progress click reentry through ${mode} cannot continue an obsolete seek`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate((mode) => {
|
||||
const art = window.art
|
||||
let seeks = 0
|
||||
let bars = 0
|
||||
art.on('seek', () => seeks++)
|
||||
art.on('setBar', (type) => {
|
||||
if (type !== 'played' || bars++)
|
||||
return
|
||||
if (mode === 'remove')
|
||||
art.controls.remove('progress')
|
||||
else if (mode === 'destroy')
|
||||
art.destroy()
|
||||
else
|
||||
art.url = '/test/pattern.mp4?progress=reentrant'
|
||||
})
|
||||
const progress = art.template.$progress
|
||||
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
|
||||
const result = { seeks, bars }
|
||||
art.destroy()
|
||||
return result
|
||||
}, mode)
|
||||
expect(result).toEqual({ seeks: core === 'published' ? 1 : 0, bars: 1 })
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core}: real progress click keeps setBar before seek and clamps the edges`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const rect = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
window.progressOrder = []
|
||||
art.on('setBar', (type, percent) => {
|
||||
if (type === 'played')
|
||||
window.progressOrder.push(['setBar', percent])
|
||||
})
|
||||
art.on('seek', () => window.progressOrder.push(['seek']))
|
||||
const rect = art.template.$progress.getBoundingClientRect()
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||
})
|
||||
await page.mouse.click(rect.x, rect.y)
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
|
||||
expect(await page.evaluate(() => window.art.currentTime / window.art.duration)).toBeCloseTo(0.5, 1)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const firstSeek = window.progressOrder.findIndex(item => item[0] === 'seek')
|
||||
const previous = window.progressOrder[firstSeek - 1]
|
||||
const progress = art.template.$progress
|
||||
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left - 100 }))
|
||||
const start = art.currentTime
|
||||
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().right + 100 }))
|
||||
const end = art.currentTime
|
||||
const duration = art.duration
|
||||
art.destroy()
|
||||
return { previous, start, end, duration }
|
||||
})
|
||||
expect(result.previous[0]).toBe('setBar')
|
||||
expect(result.previous[1]).toBeCloseTo(0.5, 1)
|
||||
expect(result.start).toBe(0)
|
||||
expect(result.end).toBeCloseTo(result.duration, 1)
|
||||
})
|
||||
|
||||
test(`${core}: a progress drag ends when its source is replaced and can restart on the new source`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const stale = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
window.dragSeeks = 0
|
||||
art.on('seek', () => window.dragSeeks++)
|
||||
const progress = art.template.$progress
|
||||
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
|
||||
art.url = '/test/pattern.mp4?progress=new-source'
|
||||
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
|
||||
return window.dragSeeks
|
||||
})
|
||||
expect(stale).toBe(core === 'published' ? 1 : 0)
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 2)).toBe(true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const progress = art.template.$progress
|
||||
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
|
||||
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
|
||||
art.emit('document:mouseup', new MouseEvent('mouseup'))
|
||||
const seeks = window.dragSeeks
|
||||
art.destroy()
|
||||
return seeks
|
||||
})).toBe(stale + 1)
|
||||
})
|
||||
|
||||
test(`${core}: a caller-owned media Blob URL remains readable after switching and destroying the player`, async ({ page, browserName }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(async () => {
|
||||
window.blobCapabilities = []
|
||||
let blob
|
||||
let fallback
|
||||
for (const extension of ['mp4', 'webm']) {
|
||||
const candidate = await (await fetch(`/test/pattern.${extension}`)).blob()
|
||||
fallback ||= candidate
|
||||
const url = URL.createObjectURL(candidate)
|
||||
const video = document.createElement('video')
|
||||
const capability = await new Promise((resolve) => {
|
||||
video.onloadedmetadata = () => resolve({ extension, width: video.videoWidth, error: 0 })
|
||||
video.onerror = () => resolve({ extension, width: video.videoWidth, error: video.error.code })
|
||||
video.src = url
|
||||
video.load()
|
||||
})
|
||||
window.blobCapabilities.push(capability)
|
||||
video.onloadedmetadata = null
|
||||
video.onerror = null
|
||||
video.removeAttribute('src')
|
||||
video.load()
|
||||
URL.revokeObjectURL(url)
|
||||
if (!capability.error) {
|
||||
blob = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
window.blobPlayable = !!blob
|
||||
blob ||= fallback
|
||||
window.sharedBlobSize = blob.size
|
||||
window.sharedBlobUrl = URL.createObjectURL(blob)
|
||||
window.borrowedRevocations = []
|
||||
const original = URL.revokeObjectURL
|
||||
URL.revokeObjectURL = function (url) {
|
||||
window.borrowedRevocations.push(url)
|
||||
return original.call(this, url)
|
||||
}
|
||||
window.createPlayer(window.blobPlayable ? window.sharedBlobUrl : '/test/pattern.mp4')
|
||||
})
|
||||
const capabilities = await page.evaluate(() => window.blobCapabilities)
|
||||
await testInfo.attach('native-blob-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) })
|
||||
if (!capabilities.some(item => item.error === 0)) {
|
||||
expect(browserName).toBe('webkit')
|
||||
expect(capabilities).toEqual([{ extension: 'mp4', width: 0, error: 4 }, { extension: 'webm', width: 0, error: 4 }])
|
||||
}
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
// The Windows WebKit runtime cannot decode either Blob fixture natively.
|
||||
// Still exercise actual URL assignment/replacement and byte ownership there.
|
||||
if (!window.blobPlayable)
|
||||
art.url = window.sharedBlobUrl
|
||||
const assigned = art.video.src === window.sharedBlobUrl
|
||||
await art.switchUrl('/test/pattern.mp4?borrowed=replaced')
|
||||
const read = async () => {
|
||||
try {
|
||||
return (await (await fetch(window.sharedBlobUrl)).blob()).size
|
||||
}
|
||||
catch (error) {
|
||||
return error.name
|
||||
}
|
||||
}
|
||||
const afterSwitch = await read()
|
||||
art.destroy()
|
||||
const afterDestroy = await read()
|
||||
const revoked = window.borrowedRevocations.filter(url => url === window.sharedBlobUrl).length
|
||||
URL.revokeObjectURL(window.sharedBlobUrl)
|
||||
return { afterSwitch, afterDestroy, revoked, size: window.sharedBlobSize, assigned, decodedBlob: window.blobPlayable }
|
||||
})
|
||||
expect(result.size).toBeGreaterThan(100)
|
||||
expect(result.assigned).toBe(true)
|
||||
await testInfo.attach('borrowed-url-evidence', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
expect(result.revoked).toBe(core === 'published' ? 1 : 0)
|
||||
expect(result.afterSwitch).toBe(core === 'published' ? 'TypeError' : result.size)
|
||||
expect(result.afterDestroy).toBe(core === 'published' ? 'TypeError' : result.size)
|
||||
})
|
||||
|
||||
test(`${core}: quality selectors retain default labels, source position and empty-array behavior`, async ({ page, browserName }, testInfo) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
const art = window.art
|
||||
window.qualityNativeEvents = []
|
||||
for (const key of ['currentTime', 'playbackRate', 'src']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, key)
|
||||
Object.defineProperty(art.video, key, {
|
||||
configurable: true,
|
||||
get() { return descriptor.get.call(this) },
|
||||
set(value) {
|
||||
window.qualityNativeEvents.push({ key, value, time: this.currentTime, seeking: this.seeking, source: this.currentSrc })
|
||||
descriptor.set.call(this, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const event of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'ratechange', 'pause', 'playing'])
|
||||
art.video.addEventListener(event, () => window.qualityNativeEvents.push({ event, time: art.currentTime, seeking: art.video.seeking, source: art.video.currentSrc }))
|
||||
art.playbackRate = 1.5
|
||||
art.currentTime = 2
|
||||
window.qualityRestarts = []
|
||||
art.on('restart', url => window.qualityRestarts.push(url))
|
||||
window.qualityOptions = [
|
||||
{ html: 'Quality A', url: '/test/pattern.mp4?quality=A' },
|
||||
{ html: 'Quality B', url: '/test/pattern.mp4?quality=B', default: true },
|
||||
]
|
||||
art.quality = window.qualityOptions
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - 2) < 0.05 && !window.art.video.seeking)).toBe(true)
|
||||
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality B')
|
||||
await page.locator('.art-control-quality').hover()
|
||||
await page.locator('.art-control-quality .art-selector-item').filter({ hasText: 'Quality A' }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.url.includes('quality=A'))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.qualityRestarts)).toEqual(['/test/pattern.mp4?quality=A'])
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 3 && !window.art.video.seeking)).toBe(true)
|
||||
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality A')
|
||||
await testInfo.attach('quality-native-events', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.qualityNativeEvents)) })
|
||||
const restoredTime = await page.evaluate(() => window.art.currentTime)
|
||||
if (core === 'published' && browserName === 'webkit')
|
||||
expect(restoredTime).toBeLessThan(0.05)
|
||||
else
|
||||
expect(restoredTime).toBeCloseTo(2, 1)
|
||||
expect(await page.evaluate(() => window.art.playbackRate)).toBe(1.5)
|
||||
expect(await page.evaluate(() => window.qualityOptions.map(item => !!item.default))).toEqual([true, false])
|
||||
const empty = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.quality = []
|
||||
const result = { text: art.controls.quality.textContent, read: art.quality, hasGetter: !!Object.getOwnPropertyDescriptor(art, 'quality').get }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(empty).toEqual({ text: '', read: undefined, hasGetter: false })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, crossOrigin = false, control = false) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(({ crossOrigin, control }) => {
|
||||
const url = new URL('/test/pattern.mp4', location.href)
|
||||
if (crossOrigin)
|
||||
url.hostname = 'localhost'
|
||||
window.art = new window.Artplayer({ container: '.player', url: url.href, muted: true, screenshot: control })
|
||||
}, { crossOrigin, control })
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(() => window.art.play())
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.evaluate(() => window.art.pause())
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: captures decoded PNG pixels and consumer-owned Blob URLs`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const data = await art.getDataURL()
|
||||
const url = await art.getBlobUrl()
|
||||
const image = new Image()
|
||||
image.src = data
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.width
|
||||
canvas.height = image.height
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(image, 0, 0)
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data
|
||||
const colors = new Set()
|
||||
for (let index = 0; index < pixels.length; index += 4)
|
||||
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
|
||||
const expected = [art.video.videoWidth, art.video.videoHeight]
|
||||
const descriptors = ['getDataURL', 'getBlobUrl', 'screenshot'].map((name) => {
|
||||
const d = Object.getOwnPropertyDescriptor(art, name)
|
||||
return [d.writable, d.configurable, d.enumerable]
|
||||
})
|
||||
art.destroy()
|
||||
const blob = await (await fetch(url)).blob()
|
||||
URL.revokeObjectURL(url)
|
||||
return { png: data.startsWith('data:image/png;base64,'), dimensions: [image.width, image.height], expected, colors: colors.size, blobType: blob.type, blobSize: blob.size, descriptors }
|
||||
})
|
||||
expect(result.png).toBe(true)
|
||||
expect(result.dimensions).toEqual(result.expected)
|
||||
expect(result.dimensions[0]).toBeGreaterThan(0)
|
||||
expect(result.colors).toBeGreaterThan(10)
|
||||
expect(result.blobType).toBe('image/png')
|
||||
expect(result.blobSize).toBeGreaterThan(100)
|
||||
expect(result.descriptors).toEqual(Array.from({ length: 3 }, () => [false, false, false]))
|
||||
})
|
||||
|
||||
test(`${core}: screenshot preserves download filename, event data and Promise result`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const events = []
|
||||
art.on('screenshot', value => events.push(value))
|
||||
const screenshot = art.screenshot
|
||||
const value = await screenshot('chosen.png')
|
||||
art.destroy()
|
||||
return { count: events.length, same: events[0] === value, png: value.startsWith('data:image/png;base64,') }
|
||||
})
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('chosen.png.png')
|
||||
expect(result).toEqual({ count: 1, same: true, png: true })
|
||||
await download.delete()
|
||||
})
|
||||
|
||||
test(`${core}: actual cross-origin video rejects capture with SecurityError and notice`, async ({ page }) => {
|
||||
await setup(page, core, true)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const errors = []
|
||||
for (const name of ['getDataURL', 'getBlobUrl', 'screenshot']) {
|
||||
try {
|
||||
await art[name]()
|
||||
errors.push('resolved')
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(error.name)
|
||||
}
|
||||
}
|
||||
const result = { errors, notice: art.notice.show, text: art.template.$noticeInner.textContent.length }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result.errors).toEqual(['SecurityError', 'SecurityError', 'SecurityError'])
|
||||
expect(result.notice).toBe(true)
|
||||
expect(result.text).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test(`${core}: source replacement does not download a stale captured frame`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
let events = 0
|
||||
let downloads = 0
|
||||
const original = HTMLAnchorElement.prototype.click
|
||||
HTMLAnchorElement.prototype.click = () => {
|
||||
downloads++
|
||||
}
|
||||
art.on('screenshot', () => {
|
||||
events++
|
||||
})
|
||||
try {
|
||||
const promise = art.screenshot()
|
||||
art.url = '/test/pattern.mp4?replacement=1'
|
||||
const result = { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
|
||||
art.destroy()
|
||||
return result
|
||||
}
|
||||
finally {
|
||||
HTMLAnchorElement.prototype.click = original
|
||||
}
|
||||
})
|
||||
const oldEffects = core === 'published' ? 1 : 0
|
||||
expect(result).toEqual({ png: true, events: oldEffects, downloads: oldEffects })
|
||||
})
|
||||
|
||||
test(`${core}: screenshot toolbar rejection has exact historical error accounting`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, false, true)
|
||||
await page.evaluate((core) => {
|
||||
window.captureFailures = []
|
||||
const failure = new Error('controlled screenshot draw failure')
|
||||
const original = HTMLCanvasElement.prototype.toDataURL
|
||||
window.restoreCapture = () => {
|
||||
HTMLCanvasElement.prototype.toDataURL = original
|
||||
}
|
||||
HTMLCanvasElement.prototype.toDataURL = () => {
|
||||
throw failure
|
||||
}
|
||||
if (core === 'published') {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (event.reason === failure) {
|
||||
window.captureFailures.push(event.reason.message)
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
window.art.notice.show = false
|
||||
}, core)
|
||||
await page.locator('.art-control-screenshot').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$noticeInner.textContent)).toBe('controlled screenshot draw failure')
|
||||
if (core === 'published')
|
||||
await expect.poll(() => page.evaluate(() => window.captureFailures.length)).toBe(1)
|
||||
const known = await page.evaluate(() => {
|
||||
window.restoreCapture()
|
||||
window.art.destroy()
|
||||
return window.captureFailures
|
||||
})
|
||||
expect(known).toEqual(core === 'published' ? ['controlled screenshot draw failure'] : [])
|
||||
await testInfo.attach('known-screenshot-rejections', { contentType: 'application/json', body: JSON.stringify(known) })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: null toBlob callback rejects and reports without a page error', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(async () => {
|
||||
const prototype = HTMLCanvasElement.prototype
|
||||
const original = prototype.toBlob
|
||||
prototype.toBlob = function (callback) {
|
||||
queueMicrotask(() => callback(null))
|
||||
}
|
||||
try {
|
||||
await window.art.getBlobUrl()
|
||||
return 'resolved'
|
||||
}
|
||||
catch (error) {
|
||||
return error.message
|
||||
}
|
||||
finally {
|
||||
prototype.toBlob = original
|
||||
window.art.destroy()
|
||||
}
|
||||
})).toBe('Unable to encode screenshot blob')
|
||||
})
|
||||
|
||||
test('candidate: destroy during screenshot await suppresses the queued download and event', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
let events = 0
|
||||
let downloads = 0
|
||||
const original = HTMLAnchorElement.prototype.click
|
||||
HTMLAnchorElement.prototype.click = () => {
|
||||
downloads++
|
||||
}
|
||||
art.on('screenshot', () => {
|
||||
events++
|
||||
})
|
||||
try {
|
||||
const promise = art.screenshot()
|
||||
art.destroy()
|
||||
return { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
|
||||
}
|
||||
finally {
|
||||
HTMLAnchorElement.prototype.click = original
|
||||
}
|
||||
})).toEqual({ png: true, events: 0, downloads: 0 })
|
||||
})
|
||||
|
||||
test('candidate: cross-origin screenshot toolbar handles its internal rejected Promise', async ({ page }) => {
|
||||
await setup(page, 'candidate', true, true)
|
||||
await page.evaluate(() => {
|
||||
window.art.notice.show = false
|
||||
})
|
||||
await page.locator('.art-control-screenshot').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.notice.show)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
@@ -61,6 +61,7 @@ for (const extension of ['mp4', 'webm']) {
|
||||
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
|
||||
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
|
||||
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
|
||||
function send(req, res, bytes, filename) {
|
||||
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
|
||||
@@ -116,6 +116,57 @@ test('candidate: quality switch restores position/rate and preserves real custom
|
||||
expect(result.error).toBe(0)
|
||||
})
|
||||
|
||||
test('candidate: direct currentTime during a native quality seek is not overwritten by automatic restoration', async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?position=initial'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.currentTime = 2
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && Math.abs(window.art.currentTime - 2) < 0.05)).toBe(true)
|
||||
const result = await page.evaluate(async () => {
|
||||
const { art } = window
|
||||
let overrides = 0
|
||||
const seeks = []
|
||||
const writes = []
|
||||
for (const name of ['currentTime', 'playbackRate']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, name)
|
||||
Object.defineProperty(art.video, name, {
|
||||
configurable: true,
|
||||
get() { return descriptor.get.call(this) },
|
||||
set(value) {
|
||||
writes.push({ name, value, before: this.currentTime, seeking: this.seeking })
|
||||
descriptor.set.call(this, value)
|
||||
writes.push({ name: `${name}:after`, value: this.currentTime, seeking: this.seeking })
|
||||
},
|
||||
})
|
||||
}
|
||||
art.once('video:seeked', () => {
|
||||
overrides++
|
||||
art.currentTime = 4
|
||||
})
|
||||
art.on('video:seeked', () => seeks.push({ time: art.currentTime, seeking: art.video.seeking }))
|
||||
await art.switchQuality('/test/pattern.mp4?position=consumer')
|
||||
const result = { overrides, seeks, writes, time: art.currentTime, paused: art.video.paused, seeking: art.video.seeking }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
await testInfo.attach('quality-direct-position', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
expect(result.overrides).toBe(1)
|
||||
// Assert the player write contract separately from native seek precision.
|
||||
// WebKit may finish this reentrant native seek at a different media clock.
|
||||
const positions = result.writes.filter(item => item.name === 'currentTime')
|
||||
expect(positions).toHaveLength(2)
|
||||
expect(positions[0].value).toBeCloseTo(2, 1)
|
||||
expect(positions[1].value).toBe(4)
|
||||
expect(result.paused).toBe(true)
|
||||
expect(result.seeking).toBe(false)
|
||||
})
|
||||
|
||||
test('candidate: real switch settles when the controlled native resume rejects', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?resume=initial'))
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, scale = 1) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(({ scale }) => {
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: '/test/pattern.mp4',
|
||||
muted: true,
|
||||
thumbnails: { url: '/test/thumbnail-grid.svg', number: 100, column: 10, width: 100, height: 60, scale },
|
||||
})
|
||||
window.hoverThumbnail = (percentage) => {
|
||||
const art = window.art
|
||||
const progress = art.template.$progress
|
||||
const clientX = progress.getBoundingClientRect().left + progress.clientWidth * percentage
|
||||
art.emit('setBar', 'hover', percentage, new MouseEvent('mousemove', { clientX }))
|
||||
}
|
||||
}, { scale })
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
async function visible(page) {
|
||||
await expect.poll(() => page.evaluate(() => window.art.controls.thumbnails.style.backgroundImage)).not.toBe('')
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell`, async ({ page }, testInfo) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => window.hoverThumbnail(0.1))
|
||||
await visible(page)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const element = art.controls.thumbnails
|
||||
const row = element.style.backgroundPosition
|
||||
const dimensions = [element.style.width, element.style.height]
|
||||
window.hoverThumbnail(0.11)
|
||||
const ordinary = element.style.backgroundPosition
|
||||
window.hoverThumbnail(0.001)
|
||||
const first = element.style.backgroundPosition
|
||||
const left = element.style.left
|
||||
window.hoverThumbnail(0.999)
|
||||
const last = element.style.backgroundPosition
|
||||
const right = Number.parseFloat(element.style.left)
|
||||
const expectedRight = art.template.$progress.clientWidth - 100
|
||||
const descriptor = Object.getOwnPropertyDescriptor(art, 'thumbnails')
|
||||
return { row, ordinary, first, last, left, right, expectedRight, dimensions, immutable: !descriptor.enumerable && !descriptor.configurable }
|
||||
})
|
||||
expect(result.row).toBe(core === 'published' ? '-900px 0px' : '0px -60px')
|
||||
expect(result.ordinary).toBe('-100px -60px')
|
||||
expect(result.first).toBe(core === 'published' ? '-100px -60px' : '0px 0px')
|
||||
expect(result.last).toBe('-900px -540px')
|
||||
expect(result.left).toBe('0px')
|
||||
expect(result.right).toBe(result.expectedRight)
|
||||
expect(result.dimensions).toEqual(['100px', '60px'])
|
||||
expect(result.immutable).toBe(true)
|
||||
await testInfo.attach('thumbnail-grid', { contentType: 'image/png', body: await page.locator('.player').screenshot() })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: scaled internal thumbnails and public image results have distinct Blob URL owners`, async ({ page }) => {
|
||||
await setup(page, core, 0.5)
|
||||
await page.evaluate(() => {
|
||||
window.thumbnailRevocations = []
|
||||
const original = URL.revokeObjectURL
|
||||
URL.revokeObjectURL = function (url) {
|
||||
window.thumbnailRevocations.push(url)
|
||||
return original.call(this, url)
|
||||
}
|
||||
})
|
||||
await page.evaluate(() => window.hoverThumbnail(0.2))
|
||||
await visible(page)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const style = art.controls.thumbnails.style
|
||||
const internal = style.backgroundImage.slice(5, -2)
|
||||
const image = await window.Artplayer.utils.loadImg('/test/thumbnail-grid.svg', 0.5)
|
||||
const publicUrl = image.src
|
||||
const dimensions = [image.naturalWidth, image.naturalHeight]
|
||||
const previewSize = [style.width, style.height]
|
||||
art.destroy()
|
||||
const revokedInternal = window.thumbnailRevocations.filter(url => url === internal).length
|
||||
const revokedPublic = window.thumbnailRevocations.filter(url => url === publicUrl).length
|
||||
const blob = await (await fetch(publicUrl)).blob()
|
||||
URL.revokeObjectURL(publicUrl)
|
||||
if (!revokedInternal)
|
||||
URL.revokeObjectURL(internal)
|
||||
return { dimensions, previewSize, internalBlob: internal.startsWith('blob:'), revokedInternal, revokedPublic, publicType: blob.type }
|
||||
})
|
||||
expect(result).toEqual({ dimensions: [500, 300], previewSize: ['50px', '30px'], internalBlob: true, revokedInternal: core === 'published' ? 0 : 1, revokedPublic: 0, publicType: 'image/png' })
|
||||
})
|
||||
|
||||
test(`${core}: a delayed image follows the latest hover rather than the first request position`, async ({ page, request }) => {
|
||||
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
|
||||
let release
|
||||
const gate = new Promise(resolve => release = resolve)
|
||||
let requested
|
||||
const received = new Promise(resolve => requested = resolve)
|
||||
await page.route('**/test/thumbnail-grid.svg?delayed=1', async (route) => {
|
||||
requested()
|
||||
await gate
|
||||
await route.fulfill({ contentType: 'image/svg+xml', body: svg })
|
||||
})
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?delayed=1' }
|
||||
window.hoverThumbnail(0.2)
|
||||
})
|
||||
await received
|
||||
await page.evaluate(() => window.hoverThumbnail(0.7))
|
||||
release()
|
||||
await visible(page)
|
||||
const result = await page.evaluate(() => ({ left: Number.parseFloat(window.art.controls.thumbnails.style.left), width: window.art.template.$progress.clientWidth }))
|
||||
expect(result.left).toBeCloseTo(result.width * (core === 'published' ? 0.2 : 0.7) - 50, 4)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: removing thumbnails revokes the scaled image and a replacement control can reload', async ({ page }) => {
|
||||
await setup(page, 'candidate', 0.5)
|
||||
await page.evaluate(() => window.hoverThumbnail(0.2))
|
||||
await visible(page)
|
||||
await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const url = art.controls.thumbnails.style.backgroundImage.slice(5, -2)
|
||||
window.removedThumbnailRevocations = 0
|
||||
const original = URL.revokeObjectURL
|
||||
URL.revokeObjectURL = function (value) {
|
||||
if (value === url)
|
||||
window.removedThumbnailRevocations++
|
||||
return original.call(this, value)
|
||||
}
|
||||
art.controls.remove('thumbnails')
|
||||
art.controls.add({ name: 'thumbnails', position: 'top', index: 20 })
|
||||
window.hoverThumbnail(0.4)
|
||||
})
|
||||
await visible(page)
|
||||
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
|
||||
})
|
||||
|
||||
test('candidate: image failure permits a retry through the same thumbnail configuration', async ({ page, request }) => {
|
||||
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
|
||||
let attempts = 0
|
||||
await page.route('**/test/thumbnail-grid.svg?retry=1', async (route) => {
|
||||
attempts++
|
||||
await route.fulfill({
|
||||
status: attempts === 1 ? 503 : 200,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
contentType: 'image/svg+xml',
|
||||
body: attempts === 1 ? '<temporary-image-failure/>' : svg,
|
||||
})
|
||||
})
|
||||
await setup(page, 'candidate')
|
||||
const warning = page.waitForEvent('console', message => message.type() === 'warning' && message.text().includes('ArtPlayer thumbnail load failed'))
|
||||
await page.evaluate(() => {
|
||||
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?retry=1' }
|
||||
window.hoverThumbnail(0.2)
|
||||
})
|
||||
await warning
|
||||
await page.evaluate(() => window.hoverThumbnail(0.3))
|
||||
await visible(page)
|
||||
expect(attempts).toBe(2)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
@@ -127,6 +127,27 @@ test('quality rejection identity and captured selection closure survive disposal
|
||||
assert.equal(art.notice.show, '')
|
||||
})
|
||||
|
||||
test('quality setter retains the selected default, empty arrays, option identity and setter-only descriptor', () => {
|
||||
const { art } = fixture()
|
||||
const updates = []
|
||||
art.controls = { update: option => updates.push(option) }
|
||||
qualityMix(art)
|
||||
const options = [{ html: 'first', url: 'a' }, { html: 'chosen', url: 'b', default: true }, { html: 'later', url: 'c', default: true }]
|
||||
art.quality = options
|
||||
assert.equal(updates[0].html, 'chosen')
|
||||
assert.equal(updates[0].selector, options)
|
||||
assert.deepEqual(updates[0].style, { marginRight: '10px' })
|
||||
const empty = []
|
||||
art.quality = empty
|
||||
assert.equal(updates[1].html, '')
|
||||
assert.equal(updates[1].selector, empty)
|
||||
const descriptor = Object.getOwnPropertyDescriptor(art, 'quality')
|
||||
assert.equal(descriptor.get, undefined)
|
||||
assert.equal(descriptor.enumerable, false)
|
||||
assert.equal(descriptor.configurable, false)
|
||||
assert.equal(art.quality, undefined)
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const art = new Emitter()
|
||||
const registered = new Set()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Emitter, installProgressInteractions, getPosFromEvent, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry } = await loadModules({
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
installProgressInteractions: { file: 'packages/artplayer/src/control/progress/interactions', name: 'installProgressInteractions' },
|
||||
getPosFromEvent: { file: 'packages/artplayer/src/control/progress/position', name: 'getPosFromEvent' },
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
|
||||
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
|
||||
})
|
||||
|
||||
function mouse(name, clientX = 60, button = 0) {
|
||||
return Object.assign(new Event(name), { clientX, button })
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const progress = Object.assign(new EventTarget(), { clientWidth: 100, getBoundingClientRect: () => ({ left: 10 }) })
|
||||
const indicator = new EventTarget()
|
||||
const control = Object.assign(new EventTarget(), { querySelector: () => indicator })
|
||||
const effects = []
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $progress: progress },
|
||||
duration: 200,
|
||||
isRotate: false,
|
||||
events: {
|
||||
proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
return () => target.removeEventListener(name, callback)
|
||||
},
|
||||
remove: cleanup => cleanup(),
|
||||
},
|
||||
})
|
||||
Object.defineProperty(art, 'seek', { get: () => 0, set: value => effects.push(['seek', value]) })
|
||||
art.on('setBar', (kind, percentage) => effects.push(['setBar', kind, percentage]))
|
||||
beginLifecycle(art)
|
||||
ownEntry(art, control)
|
||||
installProgressInteractions(art, control)
|
||||
return { art, progress, control, indicator, effects }
|
||||
}
|
||||
|
||||
test('progress position clamps outside edges and retains its time and percentage calculations', () => {
|
||||
const f = fixture()
|
||||
assert.deepEqual(getPosFromEvent(f.art, mouse('click', 60)), { second: 100, time: '01:40', width: 50, percentage: 0.5 })
|
||||
assert.equal(getPosFromEvent(f.art, mouse('click', -100)).percentage, 0)
|
||||
assert.equal(getPosFromEvent(f.art, mouse('click', 1000)).percentage, 1)
|
||||
f.progress.clientWidth = 0
|
||||
assert(Number.isNaN(getPosFromEvent(f.art, mouse('click', 10)).percentage))
|
||||
})
|
||||
|
||||
test('live progress clicks and drags preserve setBar before seek and mouseup stops dragging', () => {
|
||||
const f = fixture()
|
||||
f.progress.dispatchEvent(mouse('click'))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['seek', 100]])
|
||||
f.effects.length = 0
|
||||
f.progress.dispatchEvent(mouse('mousedown'))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 85))
|
||||
f.art.emit('document:mouseup', mouse('mouseup'))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 100))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.75], ['seek', 150]])
|
||||
})
|
||||
|
||||
for (const mode of ['control removal', 'destruction', 'source replacement']) {
|
||||
test(`progress click does not seek after ${mode} inside setBar`, () => {
|
||||
const f = fixture()
|
||||
f.art.on('setBar', () => {
|
||||
if (mode === 'control removal')
|
||||
releaseEntry(f.control)
|
||||
else if (mode === 'destruction')
|
||||
getScope(f.art).dispose()
|
||||
else
|
||||
beginSource(f.art)
|
||||
})
|
||||
f.progress.dispatchEvent(mouse('click'))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
|
||||
})
|
||||
}
|
||||
|
||||
test('progress drag loses its source when media is replaced and a new mousedown can start again', () => {
|
||||
const f = fixture()
|
||||
f.progress.dispatchEvent(mouse('mousedown'))
|
||||
beginSource(f.art)
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 85))
|
||||
assert.deepEqual(f.effects, [])
|
||||
f.progress.dispatchEvent(mouse('mousedown'))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 60))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['seek', 100]])
|
||||
})
|
||||
|
||||
test('progress drag does not seek when its control is removed inside setBar', () => {
|
||||
const f = fixture()
|
||||
f.art.on('setBar', () => releaseEntry(f.control))
|
||||
f.progress.dispatchEvent(mouse('mousedown'))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 60))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
|
||||
})
|
||||
|
||||
test('progress geometry reentry cannot emit or seek after disposal', () => {
|
||||
const f = fixture()
|
||||
f.progress.getBoundingClientRect = () => {
|
||||
getScope(f.art).dispose()
|
||||
return { left: 10 }
|
||||
}
|
||||
f.progress.dispatchEvent(mouse('click'))
|
||||
assert.deepEqual(f.effects, [])
|
||||
})
|
||||
|
||||
test('a newer nested progress click supersedes the earlier seek', () => {
|
||||
const f = fixture()
|
||||
let nested = false
|
||||
f.art.on('setBar', () => {
|
||||
if (!nested) {
|
||||
nested = true
|
||||
f.progress.dispatchEvent(mouse('click', 85))
|
||||
}
|
||||
})
|
||||
f.progress.dispatchEvent(mouse('click', 60))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['setBar', 'played', 0.75], ['seek', 150]])
|
||||
})
|
||||
|
||||
test('right-button mousedown and mouseup inside setBar do not continue dragging', () => {
|
||||
const f = fixture()
|
||||
f.progress.dispatchEvent(mouse('mousedown', 60, 2))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 85))
|
||||
assert.deepEqual(f.effects, [])
|
||||
f.art.on('setBar', () => f.art.emit('document:mouseup', mouse('mouseup')))
|
||||
f.progress.dispatchEvent(mouse('mousedown'))
|
||||
f.art.emit('document:mousemove', mouse('mousemove', 60))
|
||||
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { screenshotMix, beginLifecycle, getScope, beginSource } = await loadModules({
|
||||
screenshotMix: 'packages/artplayer/src/player/screenshotMix',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
})
|
||||
|
||||
function fixture(t) {
|
||||
const calls = []
|
||||
const callbacks = []
|
||||
const context = { drawImage: (...args) => calls.push(['draw', ...args]) }
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => context,
|
||||
toDataURL: type => `data:${type};base64,frame`,
|
||||
toBlob: callback => callbacks.push(callback),
|
||||
}
|
||||
const anchor = { style: {}, click: () => calls.push(['download', anchor.download]), remove: () => calls.push(['remove']) }
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document')
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
createElement: tag => tag === 'canvas' ? canvas : anchor,
|
||||
body: { appendChild: () => {} },
|
||||
} })
|
||||
t.after(() => previous ? Object.defineProperty(globalThis, 'document', previous) : delete globalThis.document)
|
||||
const video = { videoWidth: 320, videoHeight: 180, currentTime: 62 }
|
||||
const art = { template: { $video: video }, notice: { show: '' }, emit: (...args) => calls.push(args) }
|
||||
beginLifecycle(art)
|
||||
screenshotMix(art)
|
||||
return { art, canvas, video, context, calls, callbacks, scope: getScope(art) }
|
||||
}
|
||||
|
||||
test('screenshot methods retain immutable bound descriptors, PNG dimensions and filename', async (t) => {
|
||||
const f = fixture(t)
|
||||
for (const name of ['getDataURL', 'getBlobUrl', 'screenshot']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(f.art, name)
|
||||
assert.equal(descriptor.enumerable, false)
|
||||
assert.equal(descriptor.configurable, false)
|
||||
assert.equal(descriptor.writable, false)
|
||||
}
|
||||
const screenshot = f.art.screenshot
|
||||
assert.equal(await screenshot(), 'data:image/png;base64,frame')
|
||||
assert.equal(f.canvas.width, 320)
|
||||
assert.equal(f.canvas.height, 180)
|
||||
assert.deepEqual(f.calls, [
|
||||
['draw', f.video, 0, 0],
|
||||
['download', 'artplayer_01:02.png'],
|
||||
['remove'],
|
||||
['screenshot', 'data:image/png;base64,frame'],
|
||||
])
|
||||
await screenshot('custom.png')
|
||||
assert.deepEqual(f.calls.at(-3), ['download', 'custom.png.png'])
|
||||
})
|
||||
|
||||
test('capture rejects the original drawing and security errors through both methods', async (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('tainted canvas')
|
||||
f.context.drawImage = () => {
|
||||
throw failure
|
||||
}
|
||||
await assert.rejects(f.art.getDataURL(), error => error === failure)
|
||||
await assert.rejects(f.art.getBlobUrl(), error => error === failure)
|
||||
assert.equal(f.art.notice.show, failure)
|
||||
assert.equal(f.callbacks.length, 0)
|
||||
})
|
||||
|
||||
test('synchronous toBlob failure updates notice before returning its rejected Promise', async (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('encoding failed synchronously')
|
||||
f.canvas.toBlob = () => {
|
||||
throw failure
|
||||
}
|
||||
const promise = f.art.getBlobUrl()
|
||||
assert.equal(f.art.notice.show, failure)
|
||||
await assert.rejects(promise, error => error === failure)
|
||||
})
|
||||
|
||||
test('missing canvas context rejects both public captures', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.canvas.getContext = () => null
|
||||
await assert.rejects(f.art.getDataURL(), { name: 'TypeError', message: /context/ })
|
||||
await assert.rejects(f.art.getBlobUrl(), { name: 'TypeError', message: /context/ })
|
||||
})
|
||||
|
||||
test('throwing notice setter becomes a rejection even in an asynchronous callback', async (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('consumer notice setter failed')
|
||||
Object.defineProperty(f.art.notice, 'show', {
|
||||
get: () => '',
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
const promise = f.art.getBlobUrl()
|
||||
const rejected = assert.rejects(promise, error => error === failure)
|
||||
assert.doesNotThrow(() => f.callbacks[0](null))
|
||||
await rejected
|
||||
})
|
||||
|
||||
test('late capture rejection after destruction does not update notice', async (t) => {
|
||||
const f = fixture(t)
|
||||
const promise = f.art.getBlobUrl()
|
||||
const rejected = assert.rejects(promise, /encode/)
|
||||
f.scope.dispose()
|
||||
f.callbacks[0](null)
|
||||
await rejected
|
||||
assert.equal(f.art.notice.show, '')
|
||||
})
|
||||
|
||||
test('asynchronous Blob URL failures reject without escaping the canvas callback', async (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('object URL allocation failed')
|
||||
t.mock.method(URL, 'createObjectURL', () => {
|
||||
throw failure
|
||||
})
|
||||
const promise = f.art.getBlobUrl()
|
||||
const rejected = assert.rejects(promise, error => error === failure)
|
||||
assert.doesNotThrow(() => f.callbacks[0](new Blob(['frame'])))
|
||||
await rejected
|
||||
assert.equal(f.art.notice.show, failure)
|
||||
})
|
||||
|
||||
test('null Blob rejects without allocating a URL or leaving the promise pending', async (t) => {
|
||||
const f = fixture(t)
|
||||
const allocate = t.mock.method(URL, 'createObjectURL')
|
||||
const promise = f.art.getBlobUrl()
|
||||
const rejected = assert.rejects(promise, /encode|blob/i)
|
||||
assert.doesNotThrow(() => f.callbacks[0](null))
|
||||
await rejected
|
||||
assert.equal(allocate.mock.callCount(), 0)
|
||||
})
|
||||
|
||||
test('concurrent Blob captures settle independently and transfer URL ownership to consumers', async (t) => {
|
||||
const f = fixture(t)
|
||||
const revoke = t.mock.method(URL, 'revokeObjectURL', () => {})
|
||||
t.mock.method(URL, 'createObjectURL', blob => `blob:${blob.size}`)
|
||||
const first = f.art.getBlobUrl()
|
||||
const second = f.art.getBlobUrl()
|
||||
f.callbacks[1](new Blob(['bb']))
|
||||
f.scope.dispose()
|
||||
f.callbacks[0](new Blob(['a']))
|
||||
assert.deepEqual(await Promise.all([first, second]), ['blob:1', 'blob:2'])
|
||||
assert.equal(revoke.mock.callCount(), 0)
|
||||
})
|
||||
|
||||
test('destruction during screenshot await preserves its result without download or event', async (t) => {
|
||||
const f = fixture(t)
|
||||
const promise = f.art.screenshot()
|
||||
f.scope.dispose()
|
||||
assert.equal(await promise, 'data:image/png;base64,frame')
|
||||
assert.deepEqual(f.calls, [['draw', f.video, 0, 0]])
|
||||
})
|
||||
|
||||
test('source replacement during screenshot await suppresses stale download and event', async (t) => {
|
||||
const f = fixture(t)
|
||||
const promise = f.art.screenshot()
|
||||
beginSource(f.art)
|
||||
assert.equal(await promise, 'data:image/png;base64,frame')
|
||||
assert.deepEqual(f.calls, [['draw', f.video, 0, 0]])
|
||||
})
|
||||
+209
-1
@@ -4,13 +4,15 @@ import { mock, test } from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Emitter, urlMix, switchMix, playMix, beginLifecycle, getScope } = await loadModules({
|
||||
const { Emitter, urlMix, switchMix, playMix, beginLifecycle, getScope, positionRestoration, currentTimeMix } = await loadModules({
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
urlMix: 'packages/artplayer/src/player/urlMix',
|
||||
switchMix: 'packages/artplayer/src/player/switchMix',
|
||||
playMix: 'packages/artplayer/src/player/playMix',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
positionRestoration: { file: 'packages/artplayer/src/source/restore-position', name: 'positionRestoration' },
|
||||
currentTimeMix: 'packages/artplayer/src/player/currentTimeMix',
|
||||
})
|
||||
|
||||
function createArt(customType) {
|
||||
@@ -33,6 +35,212 @@ function createArt(customType) {
|
||||
return art
|
||||
}
|
||||
|
||||
test('source assignment does not revoke a caller-owned Blob URL still used elsewhere', async (t) => {
|
||||
const art = createArt()
|
||||
const blob = new Blob(['shared media'], { type: 'video/mp4' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
t.after(() => URL.revokeObjectURL(url))
|
||||
art.url = url
|
||||
art.url = 'next.mp4'
|
||||
assert.equal(await (await fetch(url)).text(), 'shared media')
|
||||
getScope(art).dispose()
|
||||
assert.equal(await (await fetch(url)).text(), 'shared media')
|
||||
})
|
||||
|
||||
test('quality restoration waits for a pending native seek before restoring playback rate', async () => {
|
||||
const art = createArt()
|
||||
let settled = false
|
||||
const switching = art.switchQuality('next.mp4').then(() => {
|
||||
settled = true
|
||||
})
|
||||
art.playbackRate = 1
|
||||
art.template.$video.seeking = true
|
||||
art.emit('video:loadedmetadata')
|
||||
art.emit('video:canplay')
|
||||
await Promise.resolve()
|
||||
assert.equal(settled, false)
|
||||
assert.equal(art.playbackRate, 1)
|
||||
assert.equal(art.currentTime, 37)
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:seeked')
|
||||
await switching
|
||||
assert.equal(settled, true)
|
||||
assert.equal(art.playbackRate, 1.5)
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
})
|
||||
|
||||
test('a switch waiting for seeked settles on cancellation and ignores the late seek', async () => {
|
||||
const art = createArt()
|
||||
const switching = art.switchQuality('next.mp4')
|
||||
art.playbackRate = 1
|
||||
art.template.$video.seeking = true
|
||||
art.emit('video:canplay')
|
||||
getScope(art).dispose()
|
||||
await switching
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:seeked')
|
||||
assert.equal(art.playbackRate, 1)
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
})
|
||||
|
||||
test('a missed native quality seek is corrected once and settles after the second seeked', async () => {
|
||||
const art = createArt()
|
||||
let time = 37
|
||||
const writes = []
|
||||
Object.defineProperty(art, 'currentTime', {
|
||||
get: () => time,
|
||||
set(value) {
|
||||
writes.push(value)
|
||||
time = value
|
||||
art.template.$video.seeking = true
|
||||
},
|
||||
})
|
||||
let settled = false
|
||||
const switching = art.switchQuality('next.mp4').then(() => {
|
||||
settled = true
|
||||
})
|
||||
art.emit('video:loadedmetadata')
|
||||
art.emit('video:canplay')
|
||||
time = 0
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:seeked')
|
||||
await Promise.resolve()
|
||||
assert.equal(settled, false)
|
||||
assert.deepEqual(writes, [37, 37])
|
||||
assert.equal(art.playbackRate, 1.5)
|
||||
// Even a second native miss must not start an unbounded correction loop.
|
||||
time = 0
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:seeked')
|
||||
await switching
|
||||
assert.deepEqual(writes, [37, 37])
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
})
|
||||
|
||||
test('public seek during quality loading wins over metadata restoration and native correction', async () => {
|
||||
for (const beforeMetadata of [false, true]) {
|
||||
const art = createArt()
|
||||
const switching = art.switchQuality('next.mp4')
|
||||
art.template.$video.seeking = true
|
||||
if (!beforeMetadata)
|
||||
art.emit('video:loadedmetadata')
|
||||
art.currentTime = 6
|
||||
art.emit('seek', 6)
|
||||
if (beforeMetadata)
|
||||
art.emit('video:loadedmetadata')
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:canplay')
|
||||
await switching
|
||||
assert.equal(art.currentTime, 6)
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
}
|
||||
})
|
||||
|
||||
test('direct public currentTime writes during quality loading supersede automatic restoration', async () => {
|
||||
for (const beforeMetadata of [false, true]) {
|
||||
const art = createArt()
|
||||
art.duration = 100
|
||||
art.template.$video.currentTime = 37
|
||||
currentTimeMix(art)
|
||||
const switching = art.switchQuality('next.mp4')
|
||||
art.template.$video.seeking = true
|
||||
if (!beforeMetadata)
|
||||
art.emit('video:loadedmetadata')
|
||||
art.currentTime = 6
|
||||
if (beforeMetadata)
|
||||
art.emit('video:loadedmetadata')
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:canplay')
|
||||
await switching
|
||||
assert.equal(art.currentTime, 6)
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
}
|
||||
})
|
||||
|
||||
test('position restoration preserves clamping and native frame rounding', () => {
|
||||
let time = 0
|
||||
const video = { seeking: false }
|
||||
const writes = []
|
||||
const art = {
|
||||
template: { $video: video },
|
||||
get currentTime() { return time },
|
||||
set currentTime(value) {
|
||||
writes.push(value)
|
||||
time = Math.min(10, value)
|
||||
video.seeking = true
|
||||
},
|
||||
}
|
||||
const restoration = positionRestoration(art, 37, () => true)
|
||||
restoration.restore()
|
||||
time = 9.99
|
||||
video.seeking = false
|
||||
assert.equal(restoration.ready(), true)
|
||||
assert.deepEqual(writes, [37])
|
||||
time = 0
|
||||
assert.equal(restoration.ready(), false)
|
||||
assert.deepEqual(writes, [37, 10])
|
||||
})
|
||||
|
||||
test('a reentrant position read cannot correct a cancelled or manually superseded seek', () => {
|
||||
for (const boundary of ['cancel', 'manual']) {
|
||||
let active = true
|
||||
let reenter = false
|
||||
let time = 0
|
||||
let restoration
|
||||
const writes = []
|
||||
const art = {
|
||||
template: { $video: { seeking: true } },
|
||||
get currentTime() {
|
||||
if (reenter) {
|
||||
if (boundary === 'cancel')
|
||||
active = false
|
||||
else
|
||||
restoration.manual()
|
||||
}
|
||||
return time
|
||||
},
|
||||
set currentTime(value) {
|
||||
writes.push(value)
|
||||
time = value
|
||||
},
|
||||
}
|
||||
restoration = positionRestoration(art, 37, () => active)
|
||||
restoration.restore()
|
||||
art.template.$video.seeking = false
|
||||
time = 6
|
||||
reenter = true
|
||||
assert.equal(restoration.ready(), boundary === 'manual')
|
||||
assert.deepEqual(writes, [37])
|
||||
}
|
||||
})
|
||||
|
||||
test('synchronous correction seeked reentry resumes a source only once', async () => {
|
||||
const art = createArt()
|
||||
art.playing = true
|
||||
let time = 37
|
||||
let correcting = false
|
||||
Object.defineProperty(art, 'currentTime', {
|
||||
get: () => time,
|
||||
set(value) {
|
||||
time = value
|
||||
art.template.$video.seeking = !correcting
|
||||
if (correcting)
|
||||
art.emit('video:seeked')
|
||||
},
|
||||
})
|
||||
const switching = art.switchQuality('next.mp4')
|
||||
art.emit('video:loadedmetadata')
|
||||
art.emit('video:canplay')
|
||||
time = 0
|
||||
correcting = true
|
||||
art.template.$video.seeking = false
|
||||
art.emit('video:seeked')
|
||||
await switching
|
||||
assert.equal(time, 37)
|
||||
assert.equal(art.play.mock.callCount(), 1)
|
||||
assert.deepEqual(Object.keys(art.e), [])
|
||||
})
|
||||
|
||||
test('superseded switches settle before readiness and only the latest restores state/restarts', async () => {
|
||||
const art = createArt()
|
||||
const restarts = []
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { thumbnailsMix, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry, loadImg, loadThumbnailImage } = await loadModules({
|
||||
thumbnailsMix: 'packages/artplayer/src/player/thumbnailsMix',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
|
||||
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
|
||||
loadImg: { file: 'packages/artplayer/src/image/load', name: 'loadImg' },
|
||||
loadThumbnailImage: { file: 'packages/artplayer/src/image/load', name: 'loadThumbnailImage' },
|
||||
})
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const images = []
|
||||
const callbacks = []
|
||||
const errors = []
|
||||
const revoked = []
|
||||
const warnings = []
|
||||
class FakeImage {
|
||||
width = 1000
|
||||
height = 600
|
||||
naturalWidth = 1000
|
||||
src = ''
|
||||
onload = null
|
||||
onerror = null
|
||||
constructor() {
|
||||
images.push(this)
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
if (name === 'src')
|
||||
this.src = ''
|
||||
}
|
||||
}
|
||||
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toBlob: callback => callbacks.push(callback) }
|
||||
for (const [name, value] of Object.entries({ Image: FakeImage, document: { createElement: () => canvas } })) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value })
|
||||
t.after(() => previous ? Object.defineProperty(globalThis, name, previous) : delete globalThis[name])
|
||||
}
|
||||
t.mock.method(URL, 'createObjectURL', () => 'blob:thumbnail')
|
||||
t.mock.method(URL, 'revokeObjectURL', url => revoked.push(url))
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
const art = Object.assign(new Emitter(), {
|
||||
option: { thumbnails: { url: 'first.png', number: 100, column: 10, width: 100, height: 60, scale: 1, ...overrides }, isLive: false },
|
||||
template: { $progress: { clientWidth: 1000 }, $video: { videoWidth: 320, videoHeight: 180 } },
|
||||
controls: { thumbnails: { style: {} } },
|
||||
})
|
||||
beginLifecycle(art)
|
||||
ownEntry(art, art.controls.thumbnails)
|
||||
thumbnailsMix(art)
|
||||
function bar(kind, percentage, event) {
|
||||
for (const listener of art.e?.setBar || []) {
|
||||
const result = listener.fn(kind, percentage, event)
|
||||
if (result?.catch)
|
||||
result.catch(error => errors.push(error))
|
||||
}
|
||||
}
|
||||
return { art, canvas, images, callbacks, errors, revoked, warnings, scope: getScope(art), bar, hover: percentage => bar('hover', percentage) }
|
||||
}
|
||||
|
||||
test('thumbnail setter preserves option identity, live restrictions and immutable descriptor', (t) => {
|
||||
const f = fixture(t)
|
||||
const descriptor = Object.getOwnPropertyDescriptor(f.art, 'thumbnails')
|
||||
assert.equal(descriptor.enumerable, false)
|
||||
assert.equal(descriptor.configurable, false)
|
||||
const option = { url: 'next.png', number: 4 }
|
||||
f.art.thumbnails = option
|
||||
assert.equal(f.art.thumbnails, option)
|
||||
f.art.thumbnails = { url: '' }
|
||||
assert.equal(f.art.thumbnails, option)
|
||||
f.art.option.isLive = true
|
||||
f.art.thumbnails = { url: 'live.png' }
|
||||
assert.equal(f.art.thumbnails, option)
|
||||
})
|
||||
|
||||
test('thumbnail layout matches generated sprite cells and preserves progress endpoint handling', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.hover(0.1)
|
||||
f.images[0].onload()
|
||||
await flush()
|
||||
const style = f.art.controls.thumbnails.style
|
||||
assert.deepEqual(style, { backgroundImage: 'url(first.png)', height: '60px', width: '100px', backgroundPosition: '-0px -60px', left: '50px' })
|
||||
f.hover(0.11)
|
||||
assert.equal(style.backgroundPosition, '-100px -60px')
|
||||
const before = { ...style }
|
||||
f.hover(0)
|
||||
f.hover(1)
|
||||
f.hover(Number.NaN)
|
||||
assert.deepEqual(style, before)
|
||||
f.hover(0.001)
|
||||
assert.equal(style.backgroundPosition, '-0px -0px')
|
||||
assert.equal(style.left, 0)
|
||||
f.hover(0.999)
|
||||
assert.equal(style.backgroundPosition, '-900px -540px')
|
||||
assert.equal(style.left, '900px')
|
||||
})
|
||||
|
||||
test('replaced thumbnail configuration cannot be overwritten by an older completed load', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.hover(0.2)
|
||||
const oldLoad = f.images[0].onload
|
||||
f.art.thumbnails = { ...f.art.thumbnails, url: 'second.png' }
|
||||
f.hover(0.4)
|
||||
f.images[1].onload()
|
||||
await flush()
|
||||
oldLoad()
|
||||
await flush()
|
||||
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(second.png)')
|
||||
})
|
||||
|
||||
test('failed thumbnail load can retry and does not escape the internal event handler', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.hover(0.2)
|
||||
f.images[0].onerror()
|
||||
await flush()
|
||||
f.hover(0.3)
|
||||
assert.equal(f.images.length, 2)
|
||||
f.images[1].onload()
|
||||
await flush()
|
||||
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(first.png)')
|
||||
assert.deepEqual(f.errors, [])
|
||||
})
|
||||
|
||||
test('destroy cancels pending thumbnail rendering and removes its setBar subscription', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.hover(0.2)
|
||||
const loaded = f.images[0].onload
|
||||
f.scope.dispose()
|
||||
loaded()
|
||||
await flush()
|
||||
assert.deepEqual(f.art.controls.thumbnails.style, {})
|
||||
assert.equal(f.art.e?.setBar?.length || 0, 0)
|
||||
})
|
||||
|
||||
test('scaled thumbnail Blob URL is released once on configuration replacement', async (t) => {
|
||||
const f = fixture(t, { scale: 0.5 })
|
||||
f.hover(0.2)
|
||||
f.images[0].onload()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
f.images[1].onload()
|
||||
await flush()
|
||||
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(blob:thumbnail)')
|
||||
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
f.scope.dispose()
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
})
|
||||
|
||||
test('pending load renders only the latest hover and suppresses stale source coordinates', async (t) => {
|
||||
const f = fixture(t)
|
||||
f.hover(0.2)
|
||||
f.hover(0.7)
|
||||
f.images[0].onload()
|
||||
await flush()
|
||||
assert.equal(f.art.controls.thumbnails.style.left, '650px')
|
||||
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
|
||||
const before = { ...f.art.controls.thumbnails.style }
|
||||
f.hover(0.4)
|
||||
beginSource(f.art)
|
||||
f.images[1].onload()
|
||||
await flush()
|
||||
assert.deepEqual(f.art.controls.thumbnails.style, before)
|
||||
f.hover(0.5)
|
||||
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(next.png)')
|
||||
})
|
||||
|
||||
test('removing the thumbnail control releases its image and a replacement starts a new request', async (t) => {
|
||||
const f = fixture(t, { scale: 0.5 })
|
||||
f.hover(0.2)
|
||||
f.images[0].onload()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
f.images[1].onload()
|
||||
await flush()
|
||||
releaseEntry(f.art.controls.thumbnails)
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
const next = { style: {} }
|
||||
f.art.controls.thumbnails = next
|
||||
ownEntry(f.art, next)
|
||||
f.hover(0.4)
|
||||
assert.equal(f.images.length, 3)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('cancelled scaling cannot allocate a Blob URL from a late encoder callback', async (t) => {
|
||||
const f = fixture(t, { scale: 0.5 })
|
||||
f.hover(0.2)
|
||||
f.images[0].onload()
|
||||
f.scope.dispose()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
await flush()
|
||||
assert.equal(f.images.length, 1)
|
||||
assert.deepEqual(f.revoked, [])
|
||||
assert.deepEqual(f.art.controls.thumbnails.style, {})
|
||||
})
|
||||
|
||||
test('configuration replacement during rendering stops remaining stale style writes', async (t) => {
|
||||
const f = fixture(t)
|
||||
let background = ''
|
||||
Object.defineProperty(f.art.controls.thumbnails.style, 'backgroundImage', {
|
||||
get: () => background,
|
||||
set(value) {
|
||||
background = value
|
||||
f.art.thumbnails = { ...f.art.thumbnails, url: 'new.png' }
|
||||
},
|
||||
})
|
||||
f.hover(0.2)
|
||||
f.images[0].onload()
|
||||
await flush()
|
||||
assert.equal(background, 'url(first.png)')
|
||||
assert.equal(f.art.controls.thumbnails.style.width, undefined)
|
||||
assert.equal(f.art.thumbnails.url, 'new.png')
|
||||
})
|
||||
|
||||
test('public loadImg preserves function name, arity, original image and no-scaling inputs', async (t) => {
|
||||
const f = fixture(t)
|
||||
assert.equal(loadImg.name, 'loadImg')
|
||||
assert.equal(loadImg.length, 2)
|
||||
for (const scale of [undefined, 0, 1, Number.NaN]) {
|
||||
const pending = loadImg('direct.png', scale)
|
||||
const image = f.images.at(-1)
|
||||
image.onload()
|
||||
assert.equal(await pending, image)
|
||||
assert.equal(image.src, 'direct.png')
|
||||
assert.equal(image.onload, null)
|
||||
assert.equal(image.onerror, null)
|
||||
}
|
||||
assert.equal(f.callbacks.length, 0)
|
||||
})
|
||||
|
||||
test('public scaled loadImg transfers a successfully decoded Blob URL without revoking it', async (t) => {
|
||||
const f = fixture(t)
|
||||
const pending = loadImg('public.png', 0.5)
|
||||
f.images[0].onload()
|
||||
assert.deepEqual([f.canvas.width, f.canvas.height], [500, 300])
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
f.images[1].onload()
|
||||
assert.equal(await pending, f.images[1])
|
||||
f.scope.dispose()
|
||||
assert.deepEqual(f.revoked, [])
|
||||
assert.equal(f.images[1].src, 'blob:thumbnail')
|
||||
})
|
||||
|
||||
test('internal image cancellation settles undefined and detaches the image handlers', async (t) => {
|
||||
const f = fixture(t)
|
||||
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
||||
const image = f.images[0]
|
||||
const staleLoad = image.onload
|
||||
f.scope.dispose()
|
||||
assert.equal(await pending, undefined)
|
||||
assert.equal(image.onload, null)
|
||||
assert.equal(image.onerror, null)
|
||||
assert.equal(image.src, '')
|
||||
staleLoad()
|
||||
assert.equal(f.callbacks.length, 0)
|
||||
assert.equal(await loadThumbnailImage('closed.png', 1, f.scope), undefined)
|
||||
assert.equal(f.images.length, 1)
|
||||
})
|
||||
|
||||
test('null encoding result rejects public image loading without a callback escape', async (t) => {
|
||||
const f = fixture(t)
|
||||
const pending = loadImg('empty.png', 0.5)
|
||||
const rejected = assert.rejects(pending, /Unable to encode thumbnail image/)
|
||||
f.images[0].onload()
|
||||
assert.doesNotThrow(() => f.callbacks[0](null))
|
||||
await rejected
|
||||
assert.equal(f.images.length, 1)
|
||||
})
|
||||
|
||||
test('scaled image failure releases its Blob exactly once and preserves the original URL in errors', async (t) => {
|
||||
const f = fixture(t)
|
||||
const pending = loadThumbnailImage('failed.png', 0.5, f.scope)
|
||||
const rejected = assert.rejects(pending, { message: 'Image load failed: failed.png' })
|
||||
f.images[0].onload()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
f.images[1].onerror()
|
||||
await rejected
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
f.scope.dispose()
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
})
|
||||
|
||||
test('image scaling preserves thrown draw errors and rejects a missing canvas context', async (t) => {
|
||||
const f = fixture(t)
|
||||
const failure = new Error('draw failed')
|
||||
f.canvas.getContext = () => ({
|
||||
drawImage() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
const first = loadImg('first.png', 0.5)
|
||||
const rejected = assert.rejects(first, error => error === failure)
|
||||
assert.doesNotThrow(() => f.images[0].onload())
|
||||
await rejected
|
||||
f.canvas.getContext = () => null
|
||||
const second = loadImg('second.png', 0.5)
|
||||
const missing = assert.rejects(second, { name: 'TypeError', message: 'Canvas 2D context is unavailable' })
|
||||
assert.doesNotThrow(() => f.images[1].onload())
|
||||
await missing
|
||||
})
|
||||
|
||||
test('destruction during object URL allocation revokes the URL without starting a scaled image', async (t) => {
|
||||
const f = fixture(t)
|
||||
t.mock.method(URL, 'createObjectURL', () => {
|
||||
f.scope.dispose()
|
||||
return 'blob:cancelled'
|
||||
})
|
||||
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
||||
f.images[0].onload()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
assert.equal(await pending, undefined)
|
||||
assert.deepEqual(f.revoked, ['blob:cancelled'])
|
||||
assert.equal(f.images.length, 1)
|
||||
})
|
||||
|
||||
test('destruction inside the scaled image constructor does not start a late image request', async (t) => {
|
||||
const f = fixture(t)
|
||||
const ImageClass = globalThis.Image
|
||||
Object.defineProperty(globalThis, 'Image', { configurable: true, value: class extends ImageClass {
|
||||
constructor() {
|
||||
super()
|
||||
if (f.images.length === 2)
|
||||
f.scope.dispose()
|
||||
}
|
||||
} })
|
||||
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
|
||||
f.images[0].onload()
|
||||
f.callbacks[0](new Blob(['image']))
|
||||
assert.equal(await pending, undefined)
|
||||
assert.deepEqual(f.revoked, ['blob:thumbnail'])
|
||||
assert.equal(f.images[1].src, '')
|
||||
assert.equal(f.images[1].onload, null)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CaptureMedia } from '../../packages/artplayer/src/capture/frame'
|
||||
import type { ScreenshotHost } from '../../packages/artplayer/src/player/screenshotMix'
|
||||
import type Artplayer from '../../packages/artplayer/types/artplayer'
|
||||
import screenshotMix from '../../packages/artplayer/src/player/screenshotMix'
|
||||
|
||||
declare const art: Artplayer
|
||||
declare const host: ScreenshotHost
|
||||
declare const video: HTMLVideoElement
|
||||
const media: CaptureMedia = video
|
||||
const data: Promise<string> = art.getDataURL()
|
||||
const blob: Promise<string> = art.getBlobUrl()
|
||||
const screenshot: Promise<string> = art.screenshot('frame')
|
||||
const installed: void = screenshotMix(host)
|
||||
// @ts-expect-error Screenshot names remain strings.
|
||||
art.screenshot(123)
|
||||
// @ts-expect-error Captures return promises, not synchronous data URLs.
|
||||
const sync: string = art.getDataURL()
|
||||
export { blob, data, installed, media, screenshot, sync }
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ThumbnailsHost } from '../../packages/artplayer/src/player/thumbnailsMix'
|
||||
import type { ThumbnailGeometry } from '../../packages/artplayer/src/thumbnails/layout'
|
||||
import type Artplayer from '../../packages/artplayer/types/artplayer'
|
||||
import type { Thumbnails } from '../../packages/artplayer/types/option'
|
||||
import { loadImg, loadThumbnailImage } from '../../packages/artplayer/src/image/load'
|
||||
import ResourceScope from '../../packages/artplayer/src/lifecycle/scope'
|
||||
import thumbnailsMix from '../../packages/artplayer/src/player/thumbnailsMix'
|
||||
import { thumbnailLayout } from '../../packages/artplayer/src/thumbnails/layout'
|
||||
|
||||
declare const art: Artplayer
|
||||
declare const host: ThumbnailsHost
|
||||
declare const geometry: ThumbnailGeometry
|
||||
const scope = new ResourceScope()
|
||||
const installed: void = thumbnailsMix(host)
|
||||
const option: Thumbnails = { url: 'sprite.png', column: 10, number: 100 }
|
||||
art.thumbnails = option
|
||||
const current: Thumbnails = art.thumbnails
|
||||
const image: Promise<HTMLImageElement> = loadImg('sprite.png', 0.5)
|
||||
const preview: Promise<HTMLImageElement | undefined> = loadThumbnailImage('sprite.png', 0.5, scope)
|
||||
const layout = thumbnailLayout(option, geometry)
|
||||
const width: string = layout.width
|
||||
const left: number | string = layout.left
|
||||
// @ts-expect-error Scale remains a number.
|
||||
loadImg('sprite.png', 'half')
|
||||
// @ts-expect-error Thumbnail configuration requires a URL.
|
||||
art.thumbnails = { number: 100 }
|
||||
// @ts-expect-error An owned request may be cancelled before producing an image.
|
||||
const guaranteed: Promise<HTMLImageElement> = loadThumbnailImage('sprite.png', 1, scope)
|
||||
export { current, guaranteed, image, installed, left, preview, width }
|
||||
Reference in new issue
Block a user