init projects
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M791,904L567,680L280,680L280,393L56,169L112,112L848,848L791,904ZM360,600L487,600L360,473L360,600ZM680,567L600,487L600,360L473,360L393,280L680,280L680,567ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M382,640L155,413L212,356L382,526L748,160L805,217L382,640ZM200,800L200,720L760,720L760,800L200,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760Q334,760 214,678.5ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M434,663L660,436L604,380L434,550L349,465L292,522L434,663ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M700,760L740,760L740,660L840,660L840,620L740,620L740,520L700,520L700,620L600,620L600,660L700,660L700,760ZM720,840Q637,840 578.5,781.5Q520,723 520,640Q520,557 578.5,498.5Q637,440 720,440Q803,440 861.5,498.5Q920,557 920,640Q920,723 861.5,781.5Q803,840 720,840ZM160,760L160,280L480,40L800,280L800,372Q781,366 761,363Q741,360 720,360L720,320L480,140L240,320L240,680L443,680Q446,701 452,721Q458,741 467,760L160,760ZM480,410L480,410L480,410L480,410L480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Z" />
|
||||
|
||||
</vector>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z" />
|
||||
</vector>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,432L296,616L240,560L480,320L720,560L664,616L480,432Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M322.5,522.5Q340,505 340,480Q340,455 322.5,437.5Q305,420 280,420Q255,420 237.5,437.5Q220,455 220,480Q220,505 237.5,522.5Q255,540 280,540Q305,540 322.5,522.5ZM522.5,522.5Q540,505 540,480Q540,455 522.5,437.5Q505,420 480,420Q455,420 437.5,437.5Q420,455 420,480Q420,505 437.5,522.5Q455,540 480,540Q505,540 522.5,522.5ZM722.5,522.5Q740,505 740,480Q740,455 722.5,437.5Q705,420 680,420Q655,420 637.5,437.5Q620,455 620,480Q620,505 637.5,522.5Q655,540 680,540Q705,540 722.5,522.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280L840,492Q821,484 800.5,481.5Q780,479 760,482L760,313L647,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L440,760L440,764L440,840L200,840ZM200,200L200,313L200,482Q200,485 200,494.5Q200,504 200,519L200,760L200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200ZM520,920L520,797L741,577Q750,568 761,564Q772,560 783,560Q795,560 806,564.5Q817,569 826,578L863,615Q871,624 875.5,635Q880,646 880,657Q880,668 876,679.5Q872,691 863,700L643,920L520,920ZM820,657L820,657L783,620L783,620L820,657ZM580,860L618,860L739,738L721,719L702,701L580,822L580,860ZM721,719L702,701L702,701L739,738L739,738L721,719ZM240,400L600,400L600,240L240,240L240,400ZM480,720Q481,720 482,720Q483,720 484,720L600,605Q600,603 600,602.5Q600,602 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M80,820L80,500L400,500L400,820L80,820ZM160,740L320,740L320,580L160,580L160,740ZM220,400L440,40L660,400L220,400ZM362,320L518,320L440,194L362,320ZM863,918L757,812Q736,826 711.5,833Q687,840 660,840Q585,840 532.5,787.5Q480,735 480,660Q480,585 532.5,532.5Q585,480 660,480Q735,480 787.5,532.5Q840,585 840,660Q840,686 833,710.5Q826,735 813,756L919,862L863,918ZM660,760Q702,760 731,731Q760,702 760,660Q760,618 731,589Q702,560 660,560Q618,560 589,589Q560,618 560,660Q560,702 589,731Q618,760 660,760ZM320,580L320,580L320,580L320,580ZM440,320L440,320L440,320Z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M680,680Q705,680 722.5,662.5Q740,645 740,620Q740,595 722.5,577.5Q705,560 680,560Q655,560 637.5,577.5Q620,595 620,620Q620,645 637.5,662.5Q655,680 680,680ZM680,800Q711,800 737,785.5Q763,771 779,747Q757,734 732,727Q707,720 680,720Q653,720 628,727Q603,734 581,747Q597,771 623,785.5Q649,800 680,800ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,427Q781,419 761,412.5Q741,406 720,403L720,256L480,166L240,256L240,444Q240,491 252.5,538Q265,585 287.5,627.5Q310,670 342,706Q374,742 413,766L413,766Q424,798 442,827Q460,856 483,879Q482,879 481.5,879.5Q481,880 480,880ZM680,880Q597,880 538.5,821.5Q480,763 480,680Q480,597 538.5,538.5Q597,480 680,480Q763,480 821.5,538.5Q880,597 880,680Q880,763 821.5,821.5Q763,880 680,880ZM480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466L480,466L480,466L480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200ZM680,360L680,280L600,280L600,200L680,200L680,120L760,120L760,200L840,200L840,280L760,280L760,360L680,360Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M713,360L600,247L656,190L713,247L854,105L911,162L713,360ZM200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,70 @@
|
||||
<resources>
|
||||
<string name="app_name">Videobox</string>
|
||||
<string name="recommendations">Главная</string>
|
||||
<string name="retry">Повторить</string>
|
||||
<string name="recent">Недавнее</string>
|
||||
<string name="details_serial">Все детали о сериале</string>
|
||||
<string name="details_movie">Все детали о фильме</string>
|
||||
<string name="desc">Описание</string>
|
||||
<string name="watch">Смотреть</string>
|
||||
<string name="seasons_overview">Доступные сезоны</string>
|
||||
<string name="select_season">Выберите сезон</string>
|
||||
<string name="season">Cезон</string>
|
||||
<string name="episodes_overview">Доступные серии</string>
|
||||
<string name="chose_episode">Выберите серию</string>
|
||||
<string name="episode">Серия</string>
|
||||
<string name="okay">Понятно</string>
|
||||
<string name="error_title">Ошибка!</string>
|
||||
<string name="unknown_error">Неизвестная ошибка</string>
|
||||
<string name="timeout_error">Превышено время ожидания ответа от сервера</string>
|
||||
<string name="server_not_responding">Сервер не отвечает</string>
|
||||
<string name="unknown_host">Нет доступа к серверу. Проверьте подключение к интернету</string>
|
||||
<string name="cant_open_video_viewer">Не найдено приложение для проигрывания видео. Пожалуйста, поставьте видео плеер</string>
|
||||
<string name="not_found">Ничего не найдено по вашему запросу</string>
|
||||
<string name="share">Поделиться</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="server_settings">Настройка сервера</string>
|
||||
<string name="custom_serv_addr">Адрес сервера</string>
|
||||
<string name="edit_custom_serv_addr">Изменение адреса сервера</string>
|
||||
<string name="account">Аккаунт</string>
|
||||
<string name="account_auth">Авторизация</string>
|
||||
<string name="account_reg">Регистрация</string>
|
||||
<string name="account_logout_auth">Выйти из аккаунта</string>
|
||||
<string name="account_login">Логин</string>
|
||||
<string name="account_password">Пароль</string>
|
||||
<string name="account_reg_hint">У меня нет аккаунта</string>
|
||||
<string name="empty_login">Логин не может быть пустым</string>
|
||||
<string name="empty_pass">Пароль не может быть пустым</string>
|
||||
<string name="theme_currently_active">Тема активна на данный момент</string>
|
||||
<string name="saved_movies">Сохранённые</string>
|
||||
<string name="saved_movies_not_found">Вы ничего не сохраняли. Может исправите это недоразумение?</string>
|
||||
<string name="url_copied">Ссылка скопирована</string>
|
||||
<string name="copy">Скопировать</string>
|
||||
<string name="search">Поиск</string>
|
||||
<string name="search_empty_now">Тут пока ничего нет</string>
|
||||
<string name="edit_password">Изменить пароль</string>
|
||||
<string name="old_password">Текущий пароль</string>
|
||||
<string name="new_password">Новый пароль</string>
|
||||
<string name="password_changed">Пароль был успешно изменён</string>
|
||||
<string name="external_player_title">Использовать внешний плеер</string>
|
||||
<string name="next_episode">Следующий эпизод</string>
|
||||
<string name="start_from">Продолжить с %1$s</string>
|
||||
<string name="offline_save">Сохранить видео</string>
|
||||
<string name="offline_video">Сохранённые видео</string>
|
||||
<string name="offline_empty">Нет сохраненных видео</string>
|
||||
<string name="offline_remove">Удалить видео</string>
|
||||
<string name="offline_state_pending">Ожидает загрузки</string>
|
||||
<string name="offline_state_downloading">Загрузка: %1$d%</string>
|
||||
<string name="offline_state_paused">Пауза: %1$d%</string>
|
||||
<string name="offline_state_error">Ошибка: %1$s</string>
|
||||
<string name="offline_state_available">Доступно офлайн</string>
|
||||
<string name="user_tags">Теги</string>
|
||||
<string name="user_tags_add">Добавить новый тег</string>
|
||||
<string name="user_tags_save">Сохранить</string>
|
||||
<string name="user_tags_edit_title">Редактирование тега</string>
|
||||
<string name="user_tags_new_tag">Новое название</string>
|
||||
<string name="user_tags_new_tag_save">Изменить название</string>
|
||||
<string name="user_tags_tag_delete">Удалить</string>
|
||||
<string name="movie_tags_empty">Настроить теги</string>
|
||||
<string name="user_tags_filter_disable">Не фильтровать</string>
|
||||
</resources>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.vbox.shared
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import ru.shadowsparky.vbox.shared.di.EventsModule
|
||||
import ru.shadowsparky.vbox.shared.di.HttpModule
|
||||
import ru.shadowsparky.vbox.shared.di.SavedModule
|
||||
|
||||
@Module(
|
||||
includes = [EventsModule::class, HttpModule::class, SavedModule::class]
|
||||
)
|
||||
@Configuration
|
||||
@ComponentScan
|
||||
class VBoxSharedModule
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.app.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DatabaseDriverFactory
|
||||
|
||||
@Single
|
||||
class AppDatabaseProvider(
|
||||
private val factory: DatabaseDriverFactory,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var appDatabase: AppDatabase? = null
|
||||
|
||||
suspend fun get(): AppDatabase = withContext(dispatcherProvider.io) {
|
||||
appDatabase?.let { return@withContext it }
|
||||
mutex.withLock {
|
||||
appDatabase?.let { return@withContext it }
|
||||
val driver = factory.createDriver()
|
||||
AppDatabase.Schema.migrate(driver, 2, 3)
|
||||
AppDatabase.Schema.migrate(driver, 3, 4)
|
||||
AppDatabase.Schema.migrate(driver, 4, 5)
|
||||
AppDatabase.Schema.migrate(driver, 5, 6)
|
||||
AppDatabase.Schema.migrate(driver, 6, 7)
|
||||
AppDatabase.invoke(driver).also { appDatabase = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.utils.io.core.toByteArray
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.model.EmptyLoginException
|
||||
import ru.shadowsparky.vbox.shared.domain.model.EmptyPasswordException
|
||||
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.model.ServerLoginException
|
||||
import ru.shadowsparky.vbox.shared.domain.model.UnknownLoginException
|
||||
|
||||
@Factory
|
||||
class AuthTokenRepositoryImpl(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
private val tokenCache: TokenStorage,
|
||||
private val hashCalculator: HmacSha512Calc
|
||||
) : AuthTokenRepository {
|
||||
|
||||
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
|
||||
return post(loginInfo, AuthTokenRepository.REGISTER_PATH, false)
|
||||
}
|
||||
|
||||
override suspend fun login(loginInfo: LoginInfo): TokenInfo {
|
||||
return post(loginInfo, AuthTokenRepository.LOGIN_PATH, true)
|
||||
}
|
||||
|
||||
override suspend fun update(refresh: TokenRequest): TokenInfo {
|
||||
return httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, AuthTokenRepository.UPDATE_PATH)
|
||||
setJsonBody(refresh)
|
||||
}.body<TokenInfo>()
|
||||
}
|
||||
|
||||
override suspend fun changePassword(request: ChangePasswordRequest) {
|
||||
val oldPassHash = hashCalculator.calc(request.oldPasswordHash.toByteArray())
|
||||
val newPassHash = hashCalculator.calc(request.newPasswordHash.toByteArray())
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, AuthTokenRepository.CHANGE_PASS_PATH)
|
||||
setJsonBody(ChangePasswordRequest(oldPassHash.toHexString(), newPassHash.toHexString()))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revoke(tokenRequest: TokenRequest) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, AuthTokenRepository.REVOKE_PATH)
|
||||
setJsonBody(tokenRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
private suspend fun post(loginInfo: LoginInfo, path: String, saveToCache: Boolean): TokenInfo {
|
||||
if (loginInfo.login.trim().isEmpty()) {
|
||||
throw EmptyLoginException()
|
||||
} else if (loginInfo.passwordHash.trim().isEmpty()) {
|
||||
throw EmptyPasswordException()
|
||||
}
|
||||
val realPassHash = hashCalculator.calc(loginInfo.passwordHash.toByteArray())
|
||||
try {
|
||||
return httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration().apply(this, path)
|
||||
setJsonBody(loginInfo.copy(passwordHash = realPassHash.toHexString()))
|
||||
}.body<TokenInfo>().apply {
|
||||
if (saveToCache) {
|
||||
tokenCache.updateToken(this)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
throw ServerLoginException(e.message ?: "Unknown error")
|
||||
} catch (e: Exception) {
|
||||
throw UnknownLoginException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
|
||||
@Factory
|
||||
class DefaultServerConfigurationRepository(
|
||||
private val storage: KeyValueStorage
|
||||
) : ServerConfigurationRepository {
|
||||
override val serverConfiguration: Flow<ServerConfiguration> = storage.get(KEY)
|
||||
.map {
|
||||
if (it != null) {
|
||||
ServerConfiguration(it)
|
||||
} else {
|
||||
ServerConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setServerConfiguration(conf: ServerConfiguration) {
|
||||
storage.put(KEY, conf.asHttpStr())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "custom_server_uri"
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.HeathCheck
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
|
||||
@Factory
|
||||
class HeathCheckImpl(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
) : HeathCheck {
|
||||
|
||||
override suspend fun check() {
|
||||
httpClient.get {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, HeathCheck.PATH)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Buffer
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
|
||||
@Factory
|
||||
class HmacSha512Calc(private val dispatcherProvider: DispatcherProvider) {
|
||||
private val hmacKey =
|
||||
"cd4204fae910ba12b9eb6650ef90852252cf20e4c55bbb3b21ac1f8cdf8d0ab53a5f49d2298824f011dbefd30fdec21ff0b485ae835dd9bccc9d377ed54c0d80"
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
suspend fun calc(data: ByteArray): ByteArray = withContext(dispatcherProvider.io) {
|
||||
val buffer = Buffer().apply { write(data) }
|
||||
val hmacStr = hmacKey.hexToByteArray().toByteString()
|
||||
buffer.hmacSha512(hmacStr).toByteArray()
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.request.url
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
|
||||
|
||||
fun ServerConfiguration.apply(
|
||||
builder: HttpRequestBuilder,
|
||||
path: String
|
||||
) {
|
||||
builder.apply {
|
||||
url(asHttpStr() + "/$path")
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> HttpRequestBuilder.setJsonBody(body: T) {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(body)
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> HttpClient.get(
|
||||
path: String,
|
||||
params: Map<String, String>,
|
||||
configuration: ServerConfiguration
|
||||
): T {
|
||||
val rsp = get {
|
||||
configuration.apply(this, path)
|
||||
params.forEach { (key, value) -> parameter(key, value) }
|
||||
}
|
||||
return rsp.body()
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.PlayerSettingsStorge
|
||||
import ru.shadowsparky.vbox.shared.domain.isJs
|
||||
|
||||
@Factory
|
||||
class PlayerSettingsStorgeImpl(
|
||||
private val storage: KeyValueStorage
|
||||
) : PlayerSettingsStorge {
|
||||
override val useExternalPlayer = flow {
|
||||
if (isJs) {
|
||||
// в js не поддерживается.
|
||||
emit(null)
|
||||
} else {
|
||||
emitAll(storage.getBoolean(KEY).map { it ?: false })
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setUseExternalPlayer(use: Boolean) {
|
||||
storage.putBoolean(KEY, use)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY = "use_external_player"
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import kotlin.math.pow
|
||||
|
||||
@Factory
|
||||
class RemoteEventListenerFactory(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationRepository: ServerConfigurationRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val json: Json,
|
||||
private val log: Log
|
||||
) {
|
||||
fun create(suffix: String): RemoteEventListener {
|
||||
return RemoteEventListenerImpl(
|
||||
httpClient,
|
||||
serverConfigurationRepository,
|
||||
authTokenCache,
|
||||
json,
|
||||
log,
|
||||
suffix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class RemoteEventListenerImpl(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationRepository: ServerConfigurationRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val json: Json,
|
||||
private val log: Log,
|
||||
private val suffix: String
|
||||
) : RemoteEventListener {
|
||||
private val eventFlow = flow {
|
||||
val config = serverConfigurationRepository.getServerConfiguration()
|
||||
val token = authTokenCache.token.first() ?: return@flow
|
||||
log.d(TAG, "listener $suffix started")
|
||||
try {
|
||||
httpClient.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
request = { url(config.asWebSocketStr() + "/$suffix") }
|
||||
) {
|
||||
outgoing.send(Frame.Text(token.token))
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val text = (incoming.receive() as? Frame.Text)?.readText()
|
||||
if (text != null) {
|
||||
val decoded = json.decodeFromString<RemoteEvent>(text)
|
||||
log.d(TAG, "receive $text $decoded")
|
||||
emit(decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.e(TAG, e, "websocket closed")
|
||||
throw e
|
||||
} finally {
|
||||
log.d(TAG, "listener $suffix finished")
|
||||
}
|
||||
}
|
||||
|
||||
override val event: Flow<RemoteEvent> = eventFlow.retryExponential { true }
|
||||
|
||||
private fun <T> Flow<T>.retryExponential(
|
||||
maxRetries: Int = Int.MAX_VALUE,
|
||||
initialDelay: Long = 5000L,
|
||||
maxDelay: Long = 300_000L,
|
||||
factor: Double = 2.0,
|
||||
shouldRetry: (Throwable) -> Boolean = { true }
|
||||
): Flow<T> = retryWhen { cause, attempt ->
|
||||
log.d(TAG, "error occurred $suffix", cause)
|
||||
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
|
||||
false
|
||||
} else {
|
||||
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
|
||||
.toLong()
|
||||
.coerceAtMost(maxDelay)
|
||||
log.d(TAG, "retry after delay $delayTime", cause)
|
||||
delay(delayTime)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "RemoteSearchEventListener"
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
|
||||
typealias WriteCallback<T> = suspend (List<T>) -> Unit
|
||||
|
||||
class RemoteSourceSynchronizer<T, K>(
|
||||
private val isSync: suspend () -> Boolean,
|
||||
private val setSync: suspend (Boolean) -> Unit,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val queryRemote: suspend () -> List<T>,
|
||||
private val queryLocal: suspend () -> List<T>,
|
||||
private val writeRemote: WriteCallback<T>,
|
||||
private val writeLocal: WriteCallback<T>,
|
||||
private val selectKey: (T) -> K
|
||||
) {
|
||||
|
||||
suspend fun syncIfNeeded() {
|
||||
if (!authTokenCache.hasAccount()) return
|
||||
if (isSync()) return
|
||||
runCatching {
|
||||
sync()
|
||||
setSync(true)
|
||||
}.onFailure {
|
||||
setSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
coroutineScope {
|
||||
val remoteDef = async { queryRemote() }
|
||||
val localDef = async { queryLocal() }
|
||||
val remoteList = remoteDef.await()
|
||||
val localList = localDef.await()
|
||||
val mergedMap = mutableMapOf<K, T>()
|
||||
localList.forEach { mergedMap[selectKey(it)] = it }
|
||||
remoteList.forEach { mergedMap[selectKey(it)] = it }
|
||||
val combinedResult = mergedMap.values.toList()
|
||||
val remoteKeys = remoteList.map(selectKey).toSet()
|
||||
val localKeys = localList.map(selectKey).toSet()
|
||||
val toRemote = combinedResult.filterNot { selectKey(it) in remoteKeys }
|
||||
val toLocal = combinedResult.filterNot { selectKey(it) in localKeys }
|
||||
val writeRemoteJob = async { if (toRemote.isNotEmpty()) writeRemote(toRemote) }
|
||||
val writeLocalJob = async { if (toLocal.isNotEmpty()) writeLocal(toLocal) }
|
||||
awaitAll(writeRemoteJob, writeLocalJob)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedTimeInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedTimeRepository
|
||||
|
||||
@Factory
|
||||
class SqlSavedTimeRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : SavedTimeRepository {
|
||||
|
||||
override suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long? =
|
||||
withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().savedTimeQueries.selectSavedTime(
|
||||
movieId,
|
||||
seasonId ?: DEFAULT_VALUE,
|
||||
episodeId ?: DEFAULT_VALUE
|
||||
).awaitAsOneOrNull()?.time
|
||||
}
|
||||
|
||||
override suspend fun setTime(time: SavedTimeInfo): Unit = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().savedTimeQueries
|
||||
.upsertSavedTime(
|
||||
time.movieId,
|
||||
time.seasonId ?: DEFAULT_VALUE,
|
||||
time.episodeId ?: DEFAULT_VALUE,
|
||||
time.time
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_VALUE = 0L
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package ru.shadowsparky.vbox.shared.data.api
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.ApiCacheQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
|
||||
class FallbackVideoApi(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val wrapper: VideoApi,
|
||||
private val json: Json,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : VideoApi {
|
||||
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
val key = "fetchNewVideos_${query};1;date;"
|
||||
return try {
|
||||
wrapper.fetchNewVideos(query).apply {
|
||||
writeByKey(key, this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
getByKey(key) ?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
val key = "fetchDetails_${id};"
|
||||
return try {
|
||||
wrapper.fetchDetails(id).apply {
|
||||
writeByKey(key, this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
getByKey(key) ?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
|
||||
val key = "fetchVideoLinks_${id};$seasonId;"
|
||||
return try {
|
||||
wrapper.fetchVideoLinks(id, seasonId).apply {
|
||||
writeByKey(key, this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
getByKey(key) ?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCache(): ApiCacheQueries {
|
||||
return dbProvider.get().apiCacheQueries
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> getByKey(key: String): T? {
|
||||
val value = getCache().selectByKey(key)
|
||||
.awaitAsOneOrNull()?.cache_value
|
||||
?: return null
|
||||
return json.decodeFromString(value)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> writeByKey(key: String, value: T) = withContext(dispatcherProvider.io) {
|
||||
getCache().insert(
|
||||
key,
|
||||
json.encodeToString(value)
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package ru.shadowsparky.vbox.shared.data.api
|
||||
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
|
||||
class PosterWithoutSuffixVideoApi(
|
||||
private val wrapper: VideoApi
|
||||
) : VideoApi by wrapper {
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
return wrapper.fetchNewVideos(query).let {
|
||||
it.copy(items = it.items.map { it.copy(poster = it.poster.withoutSuffix()) })
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
return wrapper.fetchDetails(id).let {
|
||||
it.copy(poster = it.poster.withoutSuffix())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.withoutSuffix(): String {
|
||||
return replace("_180.jpg", ".jpg")
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package ru.shadowsparky.vbox.shared.data.api
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
|
||||
class VideoApiImpl(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository
|
||||
) : VideoApi {
|
||||
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
val params = hashMapOf<String, String>().apply {
|
||||
query?.let { put(VideoApi.SEARCH_ARG, query) }
|
||||
}
|
||||
return get(VideoApi.VIDEOS_PATH, params)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
val params = hashMapOf<String, String>().apply {
|
||||
put(VideoApi.ID_ARG, id.toString())
|
||||
}
|
||||
return get(VideoApi.DETAILS_PATH, params)
|
||||
}
|
||||
|
||||
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
|
||||
val params = hashMapOf<String, String>().apply {
|
||||
put(VideoApi.ID_ARG, id.toString())
|
||||
seasonId?.let {
|
||||
put(VideoApi.SEASON_ID_ARG, it.toString())
|
||||
}
|
||||
}
|
||||
return get(VideoApi.LINKS_PATH, params)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String, params: Map<String, String>): T {
|
||||
return httpClient.get(path, params, serverConfigurationProvider.getServerConfiguration())
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package ru.shadowsparky.vbox.shared.data.recent
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.post
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
|
||||
@Factory
|
||||
@Named(DbType.REMOTE)
|
||||
class RemoteRecentlyWatchedRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationRepository: ServerConfigurationRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
@Named(EventType.RECENT) private val remoteEventListener: RemoteEventListener,
|
||||
) : RecentlyWatchedRepository {
|
||||
|
||||
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
|
||||
|
||||
override suspend fun remove(info: RecentlyWatchedInfo) {
|
||||
post(info, RecentlyWatchedRepository.REMOVE)
|
||||
}
|
||||
|
||||
override suspend fun write(info: RecentlyWatchedInfo) {
|
||||
post(info, RecentlyWatchedRepository.WRITE)
|
||||
}
|
||||
|
||||
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
|
||||
if (info.isNotEmpty()) {
|
||||
post(info, RecentlyWatchedRepository.WRITE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun post(info: RecentlyWatchedInfo, path: String) {
|
||||
post(listOf(info), path)
|
||||
}
|
||||
|
||||
private suspend fun post(info: List<RecentlyWatchedInfo>, path: String) {
|
||||
authTokenCache.token.firstOrNull() ?: return
|
||||
httpClient.post {
|
||||
obtainConfig().apply(this, path)
|
||||
setJsonBody(RecentlyWatchedRequest(info))
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryRecentlyWatched(
|
||||
movieId: Long,
|
||||
seasonId: Long?
|
||||
): Flow<List<RecentlyWatchedInfo>> {
|
||||
return remoteEventListener.event.filter { it is RemoteEvent.OnRecent && it.movieId == movieId }
|
||||
.map { queryInternal(movieId, seasonId) }
|
||||
.onStart { emit(queryInternal(movieId, seasonId)) }
|
||||
}
|
||||
|
||||
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
|
||||
return queryInternal(null, null)
|
||||
}
|
||||
|
||||
private suspend fun queryInternal(
|
||||
movieId: Long?,
|
||||
seasonId: Long?
|
||||
): List<RecentlyWatchedInfo> {
|
||||
authTokenCache.token.firstOrNull() ?: return emptyList()
|
||||
val map = hashMapOf<String, String>()
|
||||
if (movieId != null) {
|
||||
map[RecentlyWatchedRepository.MOVIE_ID_ARG] = movieId.toString()
|
||||
}
|
||||
if (seasonId != null) {
|
||||
map[RecentlyWatchedRepository.SEASON_ID_ARG] = seasonId.toString()
|
||||
}
|
||||
return httpClient.get<RecentlyWatchedResponse>(
|
||||
RecentlyWatchedRepository.GET_INFO,
|
||||
map,
|
||||
obtainConfig()
|
||||
).info
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package ru.shadowsparky.vbox.shared.data.recent
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.RecentlyWatched
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
|
||||
@Factory
|
||||
@Named(DbType.LOCAL)
|
||||
class SqlRecentlyWatchedRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : RecentlyWatchedRepository {
|
||||
|
||||
override suspend fun write(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().recentlyWatchedQueries.insertRecentlyWatched(
|
||||
info.movieId,
|
||||
info.episode ?: UNKNOWN,
|
||||
info.season ?: UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun remove(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().recentlyWatchedQueries.deleteRwByIds(
|
||||
info.movieId,
|
||||
info.episode ?: UNKNOWN,
|
||||
info.season ?: UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
override fun queryRecentlyWatched(
|
||||
movieId: Long,
|
||||
seasonId: Long?
|
||||
): Flow<List<RecentlyWatchedInfo>> {
|
||||
return flow {
|
||||
emitAll(
|
||||
dbProvider.get().recentlyWatchedQueries.selectByMovieId(movieId)
|
||||
.asFlow()
|
||||
.map { it.awaitAsList().toDomain() }
|
||||
)
|
||||
}.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> =
|
||||
withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().recentlyWatchedQueries.selectAll()
|
||||
.awaitAsList()
|
||||
.toDomain()
|
||||
}
|
||||
|
||||
private fun List<RecentlyWatched>.toDomain(): List<RecentlyWatchedInfo> {
|
||||
return map {
|
||||
val ep = if (it.episode == UNKNOWN) null else it.episode
|
||||
val season = if (it.season == UNKNOWN) null else it.season
|
||||
RecentlyWatchedInfo(it.movie_id, ep, season)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().recentlyWatchedQueries.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNKNOWN = -1L
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package ru.shadowsparky.vbox.shared.data.recent
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.query
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
|
||||
@Factory
|
||||
class SyncClientServerRecentlyWatchedRepository(
|
||||
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
|
||||
@Named(DbType.LOCAL) private val local: RecentlyWatchedRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val json: Json,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : RecentlyWatchedRepository {
|
||||
|
||||
override fun queryRecentlyWatched(movieId: Long, seasonId: Long?): Flow<List<RecentlyWatchedInfo>> {
|
||||
return authTokenCache.queryFlow(
|
||||
remote.queryRecentlyWatched(movieId, seasonId),
|
||||
local.queryRecentlyWatched(movieId, seasonId)
|
||||
) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.RECENTLY_WATCHED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<RecentlyWatchedInfo, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.RECENTLY_WATCHED) },
|
||||
setSync = { syncRepository.set(AggregateType.RECENTLY_WATCHED, true) },
|
||||
authTokenCache = authTokenCache,
|
||||
queryRemote = { remote.getAllRecentlyWatched() },
|
||||
queryLocal = { local.getAllRecentlyWatched() },
|
||||
writeRemote = { list -> remote.writeAll(list.map { it.copy(id = -1) }) },
|
||||
writeLocal = { local.writeAll(it) },
|
||||
selectKey = { "${it.movieId}|${it.season}|${it.episode}" }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
|
||||
return authTokenCache.query(
|
||||
active = { remote.getAllRecentlyWatched() },
|
||||
fallback = { local.getAllRecentlyWatched() }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun write(info: RecentlyWatchedInfo) {
|
||||
local.write(info)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.write(info)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.RECENTLY_WATCHED,
|
||||
actionType = ACTION_WRITE,
|
||||
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(info: RecentlyWatchedInfo) {
|
||||
local.remove(info)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.remove(info)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.RECENTLY_WATCHED,
|
||||
actionType = ACTION_REMOVE,
|
||||
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
|
||||
local.writeAll(info)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.writeAll(info)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(LIST, json.encodeToJsonElement(info))
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.RECENTLY_WATCHED,
|
||||
actionType = ACTION_WRITE_ALL,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LIST = "list"
|
||||
const val ACTION_WRITE = "WRITE"
|
||||
const val ACTION_REMOVE = "REMOVE"
|
||||
const val ACTION_WRITE_ALL = "WRITE_ALL"
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ru.shadowsparky.vbox.shared.data.saved
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.shadowsparky.vbox.shared.data.api.withoutSuffix
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
class PosterWithoutSuffixSavedMovieRepository(
|
||||
private val wrapper: SavedMovieRepository
|
||||
) : SavedMovieRepository by wrapper {
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
return wrapper.getAll()
|
||||
.map {
|
||||
it.map { it.copy(poster = it.poster.withoutSuffix()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package ru.shadowsparky.vbox.shared.data.saved
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.post
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoveRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
class RemoteSavedMovieRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationRepository: ServerConfigurationRepository,
|
||||
@Named(EventType.SAVED) private val eventListener: RemoteEventListener
|
||||
) : SavedMovieRepository {
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
return eventListener.event
|
||||
.filter { it is RemoteEvent.OnSaved }
|
||||
.map { getAllSingle() }
|
||||
.onStart { emit(getAllSingle()) }
|
||||
}
|
||||
|
||||
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
|
||||
|
||||
private suspend fun getAllSingle(): List<VideoDetails> {
|
||||
return httpClient.get<SavedMoviesResponse>(
|
||||
SavedMovieRepository.QUERY_ALL,
|
||||
emptyMap(),
|
||||
obtainConfig()
|
||||
).list
|
||||
}
|
||||
|
||||
override suspend fun save(details: VideoDetails) {
|
||||
httpClient.post {
|
||||
obtainConfig().apply(this, SavedMovieRepository.WRITE)
|
||||
setJsonBody(details)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isSaved(id: Long): Flow<Boolean> {
|
||||
return eventListener.event
|
||||
.filter { it is RemoteEvent.OnSaved && it.movieId == id }
|
||||
.map { isSavedSingle(id) }
|
||||
.onStart { emit(isSavedSingle(id)) }
|
||||
}
|
||||
|
||||
private suspend fun isSavedSingle(id: Long): Boolean {
|
||||
return httpClient.get<IsSavedResponse>(
|
||||
SavedMovieRepository.QUERY_SAVED,
|
||||
mapOf(
|
||||
SavedMovieRepository.MOVIE_ID_ARG to id.toString()
|
||||
),
|
||||
obtainConfig()
|
||||
).isSaved
|
||||
}
|
||||
|
||||
override suspend fun remove(id: Long) {
|
||||
httpClient.post {
|
||||
obtainConfig().apply(this, SavedMovieRepository.REMOVE)
|
||||
setJsonBody(RemoveRequest(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package ru.shadowsparky.vbox.shared.data.saved
|
||||
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.SavedMovie
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
class SqlSavedMovieRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : SavedMovieRepository {
|
||||
|
||||
private suspend fun getRequest(): Query<SavedMovie> = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().savedMovieQueries.selectAll()
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
return callbackFlow {
|
||||
val listener = Query.Listener {
|
||||
launch { send(obtain()) }
|
||||
}
|
||||
val request = getRequest()
|
||||
request.addListener(listener)
|
||||
send(obtain())
|
||||
awaitClose { request.removeListener(listener) }
|
||||
}.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
private suspend fun obtain(): List<VideoDetails> = withContext(dispatcherProvider.io) {
|
||||
val request = getRequest()
|
||||
request.awaitAsList().map {
|
||||
VideoDetails(
|
||||
it.saved_movie_id,
|
||||
it.poster_url,
|
||||
it.description,
|
||||
it.title,
|
||||
(it.flags.toInt() and SERIAL_FLAG) != 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isSaved(id: Long): Flow<Boolean> {
|
||||
return getAll().map { it.any { id == it.id } }
|
||||
}
|
||||
|
||||
override suspend fun save(details: VideoDetails) = withContext(dispatcherProvider.io) {
|
||||
val canSave = dbProvider.get().savedMovieQueries.selectAll().awaitAsList()
|
||||
.none { it.saved_movie_id == details.id }
|
||||
if (canSave) {
|
||||
dbProvider.get().savedMovieQueries.insertSavedMovie(
|
||||
details.id,
|
||||
details.poster,
|
||||
details.desc,
|
||||
details.title,
|
||||
details.flags.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(id: Long): Unit = withContext(dispatcherProvider.io) {
|
||||
dbProvider.get().savedMovieQueries.deleteSavedMovie(id)
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package ru.shadowsparky.vbox.shared.data.saved
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
|
||||
@Factory
|
||||
class SyncClientSavedMovieRepository(
|
||||
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
|
||||
@Named(DbType.LOCAL) private val local: SavedMovieRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val json: Json,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : SavedMovieRepository {
|
||||
|
||||
override suspend fun save(details: VideoDetails) {
|
||||
local.save(details)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.save(details)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SAVED_MOVIE,
|
||||
actionType = ACTION_SAVE,
|
||||
payload = json.encodeToJsonElement(VideoDetails.serializer(), details)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(id: Long) {
|
||||
local.remove(id)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.remove(id)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_ID, id)
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SAVED_MOVIE,
|
||||
actionType = ACTION_REMOVE,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
return authTokenCache.queryFlow(remote.getAll(), local.getAll()) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.SAVED_MOVIE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<VideoDetails, Long> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.SAVED_MOVIE) },
|
||||
setSync = { syncRepository.set(AggregateType.SAVED_MOVIE, true) },
|
||||
authTokenCache = authTokenCache,
|
||||
queryRemote = { remote.getAll().first() },
|
||||
queryLocal = { local.getAll().first() },
|
||||
writeRemote = { remote.saveAll(it) },
|
||||
writeLocal = { local.saveAll(it) },
|
||||
selectKey = { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
override fun isSaved(id: Long): Flow<Boolean> {
|
||||
return authTokenCache.queryFlow(remote.isSaved(id), local.isSaved(id))
|
||||
}
|
||||
|
||||
override suspend fun saveAll(details: List<VideoDetails>) {
|
||||
local.saveAll(details)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.saveAll(details)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_LIST, json.encodeToJsonElement(details))
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SAVED_MOVIE,
|
||||
actionType = ACTION_SAVE_ALL,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.clear()
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SAVED_MOVIE,
|
||||
actionType = ACTION_CLEAR,
|
||||
payload = kotlinx.serialization.json.JsonNull
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SAVE = "SAVE"
|
||||
const val ACTION_REMOVE = "REMOVE"
|
||||
const val ACTION_SAVE_ALL = "SAVE_ALL"
|
||||
const val ACTION_CLEAR = "CLEAR"
|
||||
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_LIST = "list"
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package ru.shadowsparky.vbox.shared.data.search
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.post
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
|
||||
@Named(DbType.REMOTE)
|
||||
@Factory
|
||||
class RemoteSearchRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
@Named(EventType.SEARCH) private val removeListener: RemoteEventListener
|
||||
) : SearchRepository {
|
||||
|
||||
override fun search(query: String): Flow<List<String>> {
|
||||
return removeListener.event
|
||||
.map { searchSingle(query) }
|
||||
.onStart { emit(searchSingle(query)) }
|
||||
}
|
||||
|
||||
private suspend fun searchSingle(query: String): List<String> {
|
||||
return httpClient.get<SearchQueryResponse>(
|
||||
SearchRepository.SEARCH,
|
||||
hashMapOf(SearchRepository.QUERY_ARG to query),
|
||||
obtainConf()
|
||||
).search
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: String) {
|
||||
addToSearch(listOf(query))
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: List<String>) {
|
||||
httpClient.post {
|
||||
obtainConf().apply(this, SearchRepository.ADD_TO_SEARCH)
|
||||
setJsonBody(SearchQueryRequest(query))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteFromSearch(query: String) {
|
||||
deleteInternal(query)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
deleteInternal(null)
|
||||
}
|
||||
|
||||
private suspend fun deleteInternal(query: String?) {
|
||||
httpClient.delete {
|
||||
obtainConf().apply(this, SearchRepository.CLEAR)
|
||||
setJsonBody(SearchDeleteRequest(query))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun obtainConf(): ServerConfiguration {
|
||||
return serverConfigurationProvider.getServerConfiguration()
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package ru.shadowsparky.vbox.shared.data.search
|
||||
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.SearchInfo
|
||||
import ru.shadowsparky.vbox.domain.SearchInfoQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
@Named(DbType.LOCAL)
|
||||
@Factory
|
||||
class SqlSearchRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : SearchRepository {
|
||||
|
||||
override fun search(query: String): Flow<List<String>> {
|
||||
return flow {
|
||||
emitAll(
|
||||
if (query.isNotEmpty()) {
|
||||
getQueries().selectByQuery(query.addWildcard()).toDomain()
|
||||
} else {
|
||||
getQueries().selectAll().toDomain()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Query<SearchInfo>.toDomain(): Flow<List<String>> {
|
||||
return asFlow()
|
||||
.map { list ->
|
||||
list.awaitAsList().map { it.query }
|
||||
}.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
|
||||
getQueries().deleteAll()
|
||||
}
|
||||
|
||||
override suspend fun deleteFromSearch(query: String): Unit = withContext(dispatcherProvider.io) {
|
||||
getQueries().deleteSearchInfo(query)
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: String) = withContext(dispatcherProvider.io) {
|
||||
if (getQueries().selectByQuery(query).awaitAsOneOrNull() == null) {
|
||||
getQueries().insertSearchInfo(query)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getQueries(): SearchInfoQueries {
|
||||
return dbProvider.get().searchInfoQueries
|
||||
}
|
||||
|
||||
private fun String.addWildcard(): String {
|
||||
return if (this.endsWith("%")) this else "$this%"
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package ru.shadowsparky.vbox.shared.data.search
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
|
||||
@Factory
|
||||
class SyncClientServerSearchRepository(
|
||||
@Named(DbType.REMOTE) private val remote: SearchRepository,
|
||||
@Named(DbType.LOCAL) private val local: SearchRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val authTokenCache: TokenStorage,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : SearchRepository {
|
||||
|
||||
override fun search(query: String): Flow<List<String>> {
|
||||
return authTokenCache.queryFlow(
|
||||
remote.search(query),
|
||||
local.search(query)
|
||||
) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.SEARCH)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<String, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.SEARCH) },
|
||||
setSync = { syncRepository.set(AggregateType.SEARCH, true) },
|
||||
authTokenCache = authTokenCache,
|
||||
queryRemote = { remote.search("").first() },
|
||||
queryLocal = { local.search("").first() },
|
||||
writeRemote = { remote.addToSearch(it) },
|
||||
writeLocal = { local.addToSearch(it) },
|
||||
selectKey = { it }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: String) {
|
||||
local.addToSearch(query)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.addToSearch(query)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_QUERY, query)
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SEARCH,
|
||||
actionType = ACTION_ADD,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteFromSearch(query: String) {
|
||||
local.deleteFromSearch(query)
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.deleteFromSearch(query)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_QUERY, query)
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SEARCH,
|
||||
actionType = ACTION_DELETE,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (authTokenCache.hasAccount()) {
|
||||
try {
|
||||
remote.clear()
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.SEARCH,
|
||||
actionType = ACTION_CLEAR,
|
||||
payload = JsonNull
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_ADD = "ADD"
|
||||
const val ACTION_DELETE = "DELETE"
|
||||
const val ACTION_CLEAR = "CLEAR"
|
||||
|
||||
const val KEY_QUERY = "query"
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync
|
||||
|
||||
import org.koin.core.annotation.Singleton
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
|
||||
@Singleton
|
||||
class PendingOperationSynchronizer(
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
strategies: List<AggregateSyncStrategy>
|
||||
) {
|
||||
private val strategiesMap = strategies.associateBy { it.aggregateType }
|
||||
|
||||
suspend fun sync(type: AggregateType): Boolean {
|
||||
val strategy = strategiesMap[type] ?: error("No sync strategy registered for type: $type")
|
||||
val operations = pendingRepo.query(type)
|
||||
for (operation in operations) {
|
||||
try {
|
||||
strategy.execute(operation.actionType, operation.payload)
|
||||
pendingRepo.delete(operation.id)
|
||||
} catch (_: IllegalStateException) {
|
||||
pendingRepo.delete(operation.id)
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.PendingOperationQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import kotlin.time.Clock
|
||||
|
||||
@Factory
|
||||
class SqlPendingOperationRepository(
|
||||
private val db: AppDatabaseProvider,
|
||||
private val json: Json,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : PendingOperationRepository {
|
||||
|
||||
private suspend fun getQueries(): PendingOperationQueries {
|
||||
return db.get().pendingOperationQueries
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long): Unit = withContext(dispatcherProvider.io) {
|
||||
getQueries().deleteOperation(id)
|
||||
}
|
||||
|
||||
override suspend fun query(type: AggregateType): List<OperationInfo> = withContext(dispatcherProvider.io) {
|
||||
val records = getQueries().getOperationsByAggregate(type.name).executeAsList()
|
||||
records.map { record ->
|
||||
OperationInfo(
|
||||
id = record.id,
|
||||
type = type,
|
||||
actionType = record.actionType,
|
||||
payload = record.payload?.let { json.parseToJsonElement(it) } ?: JsonNull
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun insert(operationInfo: OperationInfo): Unit = withContext(dispatcherProvider.io) {
|
||||
val payloadString = json.encodeToString(operationInfo.payload)
|
||||
getQueries().insertOperation(
|
||||
aggregateType = operationInfo.type.name,
|
||||
actionType = operationInfo.actionType,
|
||||
payload = payloadString,
|
||||
createdAt = Clock.System.now().epochSeconds
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
|
||||
getQueries().clear()
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType.RECENTLY_WATCHED
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType.SAVED_MOVIE
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType.SEARCH
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
|
||||
@Factory
|
||||
class SyncRepositoryImpl(
|
||||
private val storage: KeyValueStorage
|
||||
) : SyncRepository {
|
||||
|
||||
override suspend fun isSynchronized(type: AggregateType): Boolean {
|
||||
return storage.getBoolean(type.calcKey()).first() ?: false
|
||||
}
|
||||
|
||||
override suspend fun set(type: AggregateType, state: Boolean) {
|
||||
storage.putBoolean(type.calcKey(), state)
|
||||
}
|
||||
|
||||
private fun AggregateType.calcKey(): String {
|
||||
return when (this) {
|
||||
SEARCH -> SEARCH_KEY
|
||||
RECENTLY_WATCHED -> RECENT_KEY
|
||||
SAVED_MOVIE -> SAVED_KEY
|
||||
else -> name.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SEARCH_KEY = "search"
|
||||
const val RECENT_KEY = "recent"
|
||||
const val SAVED_KEY = "saved"
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.tag.SyncMovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
|
||||
@Factory
|
||||
class MovieTagSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: MovieTagRepository
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.MOVIE_TAG
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
val payloadObj = payload.jsonObject
|
||||
val movieId = payloadObj[SyncMovieTagRepository.KEY_MOVIE_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncMovieTagRepository.KEY_MOVIE_ID}")
|
||||
val tagId = payloadObj[SyncMovieTagRepository.KEY_TAG_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncMovieTagRepository.KEY_TAG_ID}")
|
||||
when (actionType) {
|
||||
SyncMovieTagRepository.ACTION_LINK -> remote.link(movieId, tagId)
|
||||
SyncMovieTagRepository.ACTION_UNLINK -> remote.unlink(movieId, tagId)
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.recent.SyncClientServerRecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
|
||||
@Factory
|
||||
class RecentlyWatchedSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
|
||||
private val json: Json
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.RECENTLY_WATCHED
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
when (actionType) {
|
||||
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE -> {
|
||||
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
|
||||
remote.write(info)
|
||||
}
|
||||
SyncClientServerRecentlyWatchedRepository.ACTION_REMOVE -> {
|
||||
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
|
||||
remote.remove(info)
|
||||
}
|
||||
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE_ALL -> {
|
||||
val payloadObj = payload.jsonObject
|
||||
val listElement = payloadObj[SyncClientServerRecentlyWatchedRepository.LIST] ?: error("Missing list key")
|
||||
val list = json.decodeFromJsonElement<List<RecentlyWatchedInfo>>(listElement)
|
||||
remote.writeAll(list)
|
||||
}
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.saved.SyncClientSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
@Factory
|
||||
class SavedMovieSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
|
||||
private val json: Json
|
||||
) : AggregateSyncStrategy {
|
||||
|
||||
override val aggregateType: AggregateType = AggregateType.SAVED_MOVIE
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
when (actionType) {
|
||||
SyncClientSavedMovieRepository.ACTION_SAVE -> {
|
||||
val details = json.decodeFromJsonElement<VideoDetails>(payload)
|
||||
remote.save(details)
|
||||
}
|
||||
SyncClientSavedMovieRepository.ACTION_REMOVE -> {
|
||||
val id = payload.jsonObject[SyncClientSavedMovieRepository.KEY_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncClientSavedMovieRepository.KEY_ID}")
|
||||
remote.remove(id)
|
||||
}
|
||||
SyncClientSavedMovieRepository.ACTION_SAVE_ALL -> {
|
||||
val listElement = payload.jsonObject[SyncClientSavedMovieRepository.KEY_LIST]
|
||||
?: error("Missing ${SyncClientSavedMovieRepository.KEY_LIST}")
|
||||
val list = json.decodeFromJsonElement<List<VideoDetails>>(listElement)
|
||||
remote.saveAll(list)
|
||||
}
|
||||
SyncClientSavedMovieRepository.ACTION_CLEAR -> {
|
||||
remote.clear()
|
||||
}
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.search.SyncClientServerSearchRepository
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
@Factory
|
||||
class SearchSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: SearchRepository
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.SEARCH
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
when (actionType) {
|
||||
SyncClientServerSearchRepository.ACTION_ADD -> {
|
||||
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
|
||||
remote.addToSearch(query)
|
||||
}
|
||||
SyncClientServerSearchRepository.ACTION_DELETE -> {
|
||||
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
|
||||
remote.deleteFromSearch(query)
|
||||
}
|
||||
SyncClientServerSearchRepository.ACTION_CLEAR -> remote.clear()
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.tag.SyncUserTagRepository
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class UserTagSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: UserTagRepository
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.USER_TAG
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
val payloadObj = payload.jsonObject
|
||||
when (actionType) {
|
||||
SyncUserTagRepository.ACTION_ADD -> {
|
||||
val tag = payloadObj[SyncUserTagRepository.KEY_TAG]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_TAG}")
|
||||
remote.add(tag)
|
||||
}
|
||||
SyncUserTagRepository.ACTION_EDIT -> {
|
||||
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
|
||||
val newTag = payloadObj[SyncUserTagRepository.KEY_NEW_TAG]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_NEW_TAG}")
|
||||
remote.edit(id, newTag)
|
||||
}
|
||||
SyncUserTagRepository.ACTION_DELETE -> {
|
||||
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
|
||||
remote.delete(id)
|
||||
}
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.post
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
|
||||
@Named(DbType.REMOTE)
|
||||
@Factory
|
||||
class RemoteMovieTagRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
@Named(EventType.MOVIE_TAG) private val movieTagListener: RemoteEventListener,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> {
|
||||
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
|
||||
.filter { it.movieId == movieId }
|
||||
.map { querySingle(movieId) }
|
||||
.onStart { emit(querySingle(movieId)) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
private suspend fun querySingle(movieId: Long): List<TagInfo> {
|
||||
return httpClient.get<TagResponse>(
|
||||
MovieTagRepository.QUERY,
|
||||
hashMapOf(MovieTagRepository.MOVIE_ID to movieId.toString()),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).tags
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
|
||||
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
|
||||
.map { queryMoviesSingle(tagId) }
|
||||
.onStart { emit(queryMoviesSingle(tagId)) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
private suspend fun queryMoviesSingle(tagId: Long): Set<Long> {
|
||||
return httpClient.get<LinkedMovies>(
|
||||
MovieTagRepository.QUERY_MOVIES,
|
||||
hashMapOf(MovieTagRepository.TAG_ID to tagId.toString()),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).ids
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, MovieTagRepository.LINK)
|
||||
setJsonBody(LinkRequest(movieId, tagId))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
httpClient.delete {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, MovieTagRepository.UNLINK)
|
||||
setJsonBody(LinkRequest(movieId, tagId))
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.post
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.get
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.AddRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
@Named(DbType.REMOTE)
|
||||
class RemoteUserTagRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
@Named(EventType.USER_TAG) private val userTagListener: RemoteEventListener,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = userTagListener.event.filterIsInstance<RemoteEvent.OnUserTag>()
|
||||
.map { queryUserTagsSingle() }
|
||||
.onStart { emit(queryUserTagsSingle()) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
|
||||
private suspend fun queryUserTagsSingle(): List<TagInfo> {
|
||||
return httpClient.get<TagResponse>(
|
||||
UserTagRepository.LIST,
|
||||
emptyMap(),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).tags
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.ADD)
|
||||
setJsonBody(AddRequest(tag))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.EDIT)
|
||||
setJsonBody(EditRequest(id, newTag))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
httpClient.delete {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.DELETE)
|
||||
setJsonBody(DeleteRequest(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.MovieTagQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
|
||||
@Named(DbType.LOCAL)
|
||||
@Factory
|
||||
class SqlMovieTagRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> = flow {
|
||||
emitAll(
|
||||
getQueries().query(movieId = movieId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { tags ->
|
||||
tags.map { tag ->
|
||||
TagInfo(
|
||||
id = tag.id,
|
||||
tag = tag.tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> = flow {
|
||||
emitAll(
|
||||
getQueries().queryMovies(tagId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { it.toSet() }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
getQueries().link(
|
||||
movieId = movieId,
|
||||
tagId = tagId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
getQueries().unlink(
|
||||
movieId = movieId,
|
||||
tagId = tagId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
getQueries().clear()
|
||||
}
|
||||
|
||||
private suspend fun getQueries(): MovieTagQueries {
|
||||
return dbProvider.get().movieTagQueries
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.UserTagQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Named(DbType.LOCAL)
|
||||
@Factory
|
||||
class SqlUserTagRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = flow {
|
||||
emitAll(
|
||||
getQueries().selectAll()
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { tags ->
|
||||
tags.map { tag ->
|
||||
TagInfo(
|
||||
id = tag.id,
|
||||
tag = tag.tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
runCatching { getQueries().insert(tag) }
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
getQueries().update(
|
||||
id = id,
|
||||
newTag = newTag
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
runCatching { getQueries().delete(id) }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
getQueries().clear()
|
||||
}
|
||||
|
||||
private suspend fun getQueries(): UserTagQueries {
|
||||
return dbProvider.get().userTagQueries
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class SyncMovieTagRepository(
|
||||
@Named(DbType.LOCAL) private val local: MovieTagRepository,
|
||||
@Named(DbType.REMOTE) private val remote: MovieTagRepository,
|
||||
@Named(DbType.REMOTE) private val userTagRemote: UserTagRepository,
|
||||
@Named(DbType.LOCAL) private val userTagLocal: UserTagRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> {
|
||||
return tokenStorage.queryFlow(remote.query(movieId), local.query(movieId)) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.MOVIE_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<MovieTagLink, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.MOVIE_TAG) },
|
||||
setSync = { syncRepository.set(AggregateType.MOVIE_TAG, true) },
|
||||
authTokenCache = tokenStorage,
|
||||
queryRemote = {
|
||||
val allTags = userTagRemote.userTags.first()
|
||||
allTags.flatMap { tag ->
|
||||
val tagId = tag.id ?: return@flatMap emptyList()
|
||||
remote.queryMovies(tagId).first().map { movieId ->
|
||||
MovieTagLink(movieId = movieId, tagId = tagId)
|
||||
}
|
||||
}
|
||||
},
|
||||
queryLocal = {
|
||||
val allTags = userTagLocal.userTags.first()
|
||||
allTags.flatMap { tag ->
|
||||
val tagId = tag.id ?: return@flatMap emptyList()
|
||||
local.queryMovies(tagId).first().map { movieId ->
|
||||
MovieTagLink(movieId = movieId, tagId = tagId)
|
||||
}
|
||||
}
|
||||
},
|
||||
writeRemote = { list ->
|
||||
list.forEach { link -> remote.link(movieId = link.movieId, tagId = link.tagId) }
|
||||
},
|
||||
writeLocal = { list ->
|
||||
list.forEach { link -> local.link(movieId = link.movieId, tagId = link.tagId) }
|
||||
},
|
||||
selectKey = { "${it.movieId}|${it.tagId}" }
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MovieTagLink(
|
||||
val movieId: Long,
|
||||
val tagId: Long
|
||||
)
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
|
||||
return tokenStorage.queryFlow(remote.queryMovies(tagId), local.queryMovies(tagId))
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
local.link(movieId, tagId)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.link(movieId, tagId)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.MOVIE_TAG,
|
||||
actionType = ACTION_LINK,
|
||||
payload = buildPayload(movieId, tagId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
local.unlink(movieId, tagId)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.unlink(movieId, tagId)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.MOVIE_TAG,
|
||||
actionType = ACTION_UNLINK,
|
||||
payload = buildPayload(movieId, tagId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (tokenStorage.hasAccount()) remote.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_LINK = "LINK"
|
||||
const val ACTION_UNLINK = "UNLINK"
|
||||
|
||||
const val KEY_MOVIE_ID = "movieId"
|
||||
const val KEY_TAG_ID = "tagId"
|
||||
|
||||
private fun buildPayload(movieId: Long, tagId: Long) = buildJsonObject {
|
||||
put(KEY_MOVIE_ID, movieId)
|
||||
put(KEY_TAG_ID, tagId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.AggregateType
|
||||
import ru.shadowsparky.vbox.shared.domain.OperationInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class SyncUserTagRepository(
|
||||
@Named(DbType.LOCAL) private val local: UserTagRepository,
|
||||
@Named(DbType.REMOTE) private val remote: UserTagRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = tokenStorage.queryFlow(
|
||||
remote.userTags,
|
||||
local.userTags
|
||||
) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.USER_TAG)
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<TagInfo, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.USER_TAG) },
|
||||
setSync = { syncRepository.set(AggregateType.USER_TAG, true) },
|
||||
authTokenCache = tokenStorage,
|
||||
queryRemote = { remote.userTags.first() },
|
||||
queryLocal = { local.userTags.first() },
|
||||
writeRemote = { list ->
|
||||
list.forEach { tagInfo -> remote.add(tagInfo.tag) }
|
||||
},
|
||||
writeLocal = { list ->
|
||||
list.forEach { tagInfo -> local.add(tagInfo.tag) }
|
||||
},
|
||||
selectKey = { it.tag }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
local.add(tag)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.add(tag)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject { put(KEY_TAG, tag) }
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_ADD,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
local.edit(id, newTag)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.edit(id, newTag)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_ID, id)
|
||||
put(KEY_NEW_TAG, newTag)
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_EDIT,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
local.delete(id)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.delete(id)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject { put(KEY_ID, id) }
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_DELETE,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (tokenStorage.hasAccount()) remote.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_ADD = "ADD"
|
||||
const val ACTION_EDIT = "EDIT"
|
||||
const val ACTION_DELETE = "DELETE"
|
||||
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_TAG = "tag"
|
||||
const val KEY_NEW_TAG = "newTag"
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package ru.shadowsparky.vbox.shared.data.token
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.http.domain.RefreshTokenAction
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
|
||||
@Factory
|
||||
class RefreshTokenActionImpl(
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository
|
||||
) : RefreshTokenAction {
|
||||
override suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo {
|
||||
val result = client.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, AuthTokenRepository.UPDATE_PATH)
|
||||
setJsonBody(TokenRequest(refreshToken))
|
||||
}
|
||||
return result.body()
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ru.shadowsparky.vbox.shared.data.token
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
|
||||
@Factory
|
||||
class TokenStorageImpl(
|
||||
private val storage: KeyValueStorage
|
||||
) : TokenStorage {
|
||||
private val auth = storage.get(KEY)
|
||||
private val refresh = storage.get(REFRESH_TOKEN)
|
||||
|
||||
override val realm = "server side data storage"
|
||||
|
||||
override val token: Flow<TokenInfo?> = auth.combine(refresh) { auth, refresh ->
|
||||
auth ?: return@combine null
|
||||
TokenInfo(auth, refresh)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun updateToken(newToken: TokenInfo) {
|
||||
storage.put(KEY, newToken.token)
|
||||
newToken.refresh?.let { storage.put(REFRESH_TOKEN, it) }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
storage.remove(KEY)
|
||||
storage.remove(REFRESH_TOKEN)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "auth_token"
|
||||
const val REFRESH_TOKEN = "refresh_token"
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
object DbType {
|
||||
const val REMOTE = "remote"
|
||||
const val LOCAL = "local"
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteEventListenerFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
|
||||
@Module
|
||||
class EventsModule {
|
||||
|
||||
@Factory
|
||||
@Named(EventType.RECENT)
|
||||
fun provideRecentRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.RECENTLY_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.SEARCH)
|
||||
fun provideSearchRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.SEARCH_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.SAVED)
|
||||
fun provideSavedRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.SAVED_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.MOVIE_TAG)
|
||||
fun provideMovieTagRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.MOVIE_TAG_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.USER_TAG)
|
||||
fun provideUserTagRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.USER_TAG_CHANGED)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.data.api.FallbackVideoApi
|
||||
import ru.shadowsparky.vbox.shared.data.api.PosterWithoutSuffixVideoApi
|
||||
import ru.shadowsparky.vbox.shared.data.api.VideoApiImpl
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Module
|
||||
class HttpModule {
|
||||
|
||||
@Single
|
||||
fun provideVideoApi(
|
||||
httpClient: HttpClient,
|
||||
serverConfigurationProvider: ServerConfigurationRepository,
|
||||
json: Json,
|
||||
dbProvider: AppDatabaseProvider,
|
||||
dispatcherProvider: DispatcherProvider
|
||||
): VideoApi {
|
||||
val impl = VideoApiImpl(httpClient, serverConfigurationProvider)
|
||||
return PosterWithoutSuffixVideoApi(
|
||||
FallbackVideoApi(dbProvider, impl, json, dispatcherProvider)
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.data.saved.PosterWithoutSuffixSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.data.saved.RemoteSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.data.saved.SqlSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
|
||||
@Module
|
||||
class SavedModule {
|
||||
|
||||
@Factory
|
||||
@Named(DbType.LOCAL)
|
||||
fun provideSavedLocalRepository(
|
||||
dbProvider: AppDatabaseProvider,
|
||||
dispatcherProvider: DispatcherProvider
|
||||
): SavedMovieRepository {
|
||||
val sql = SqlSavedMovieRepository(dbProvider, dispatcherProvider)
|
||||
return PosterWithoutSuffixSavedMovieRepository(sql)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(DbType.REMOTE)
|
||||
fun provideSavedRemoteRepository(
|
||||
httpClient: HttpClient,
|
||||
serverConfigurationRepository: ServerConfigurationRepository,
|
||||
@Named(EventType.SAVED) eventListener: RemoteEventListener
|
||||
): SavedMovieRepository {
|
||||
val impl = RemoteSavedMovieRepository(httpClient, serverConfigurationRepository, eventListener)
|
||||
return PosterWithoutSuffixSavedMovieRepository(impl)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
interface DatabaseDriverFactory {
|
||||
suspend fun createDriver(): SqlDriver
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import com.seiko.imageloader.ImageLoader
|
||||
|
||||
interface ImageLoaderProvider {
|
||||
fun provide(): ImageLoader
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AddOfflineVideoUseCase(private val offlineStorage: OfflineStorage?) {
|
||||
|
||||
suspend fun add(item: OfflineVideo, uri: String) {
|
||||
offlineStorage ?: return
|
||||
val old = offlineStorage.find(item.movieId, item.seasonId, item.episodeId)
|
||||
if (old == null) {
|
||||
offlineStorage.add(item, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
interface AggregateSyncStrategy {
|
||||
val aggregateType: AggregateType
|
||||
|
||||
suspend fun execute(actionType: String, payload: JsonElement)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
|
||||
suspend fun TokenStorage.use(
|
||||
active: suspend () -> Unit,
|
||||
fallback: suspend () -> Unit
|
||||
) {
|
||||
if (hasAccount()) {
|
||||
runCatching { active() }.onFailure { fallback() }
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> TokenStorage.queryFlow(
|
||||
active: Flow<T>,
|
||||
fallback: Flow<T>,
|
||||
init: suspend () -> Unit = {}
|
||||
): Flow<T> {
|
||||
return flow {
|
||||
init()
|
||||
emitAll(if (hasAccount()) {
|
||||
active.catch { emitAll(fallback) }
|
||||
} else {
|
||||
fallback
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> TokenStorage.query(
|
||||
active: suspend () -> T,
|
||||
fallback: suspend () -> T
|
||||
): T {
|
||||
return if (hasAccount()) {
|
||||
runCatching { active() }.getOrDefault(fallback())
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.LogoutAction
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class LogoutUseCase(
|
||||
private val syncRepository: SyncRepository,
|
||||
@Named(DbType.LOCAL) private val localSearchRepo: SearchRepository,
|
||||
@Named(DbType.LOCAL) private val localRecentlyRepo: RecentlyWatchedRepository,
|
||||
@Named(DbType.LOCAL) private val localSavedMovieRepository: SavedMovieRepository,
|
||||
@Named(DbType.LOCAL) private val localMovieTagRepository: MovieTagRepository,
|
||||
@Named(DbType.LOCAL) private val localUserTagRepository: UserTagRepository,
|
||||
private val bearerAuthProvider: BearerAuthProvider,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val authTokenRepository: AuthTokenRepository,
|
||||
private val pendingOperationRepository: PendingOperationRepository
|
||||
) : LogoutAction {
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenStorage.token.firstOrNull()?.refresh?.let {
|
||||
runCatching { authTokenRepository.revoke(TokenRequest(it)) }
|
||||
}
|
||||
tokenStorage.clear()
|
||||
localSearchRepo.clear()
|
||||
localRecentlyRepo.clear()
|
||||
localSavedMovieRepository.clear()
|
||||
bearerAuthProvider.clearToken()
|
||||
localMovieTagRepository.clear()
|
||||
localUserTagRepository.clear()
|
||||
AggregateType.entries.forEach { syncRepository.set(it, false) }
|
||||
pendingOperationRepository.clear()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface OfflineStorage {
|
||||
val videos: Flow<List<OfflineVideo>>
|
||||
|
||||
suspend fun query(id: Long): Flow<OfflineVideo?>
|
||||
fun queryByArgs(movieId: Long, seasonId: Long? = null): Flow<List<OfflineVideo>>
|
||||
|
||||
suspend fun find(movieId: Long, seasonId: Long? = null, episodeId: Long? = null): OfflineVideo?
|
||||
suspend fun add(item: OfflineVideo, uri: String)
|
||||
suspend fun remove(item: OfflineVideo)
|
||||
}
|
||||
|
||||
data class OfflineVideo(
|
||||
val id: Long? = null,
|
||||
val movieId: Long,
|
||||
val seasonId: Long? = null,
|
||||
val episodeId: Long? = null,
|
||||
val state: OfflineState = OfflineState.Pending
|
||||
)
|
||||
|
||||
sealed interface OfflineState {
|
||||
|
||||
data object Pending : OfflineState
|
||||
|
||||
data class Downloading(
|
||||
val isPaused: Boolean,
|
||||
val progress: Int
|
||||
) : OfflineState
|
||||
|
||||
data class Error(
|
||||
val msg: String
|
||||
) : OfflineState
|
||||
|
||||
data class Available(
|
||||
val uri: String
|
||||
) : OfflineState
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
|
||||
@Factory
|
||||
class OfflineVideoInteractor(
|
||||
private val videoApi: VideoApi,
|
||||
private val offlineStorage: OfflineStorage?
|
||||
) {
|
||||
private val detailsCache = hashMapOf<Long, VideoDetails>()
|
||||
private val linksCache = hashMapOf<LinksKey, VideoLinksResponse>()
|
||||
|
||||
val info: Flow<List<OfflineVideoInfo>> = offlineStorage?.videos
|
||||
?.map { videos ->
|
||||
videos.groupBy { it.movieId }
|
||||
.map { info ->
|
||||
val list = info.value.groupBy { it.seasonId }
|
||||
.mapNotNull { seasonEntry ->
|
||||
val seasonId = seasonEntry.key
|
||||
val season = fetchVideoLinks(info.key, seasonId).seasons
|
||||
?.firstOrNull()
|
||||
if (seasonId == null || season == null) {
|
||||
null
|
||||
} else {
|
||||
SeasonOfflineVideo(
|
||||
seasonId,
|
||||
season.title,
|
||||
seasonEntry.value.mapNotNull { episode ->
|
||||
val links = fetchVideoLinks(episode.movieId, seasonId).seasons
|
||||
?.firstOrNull()
|
||||
?.episodes?.firstOrNull { it.episode == episode.episodeId }
|
||||
if (links == null) {
|
||||
null
|
||||
} else {
|
||||
EpisodeOfflineVideo(links, episode)
|
||||
}
|
||||
}.sortedBy { it.episode.episode }
|
||||
)
|
||||
}
|
||||
}.sortedBy { it.seasonId }
|
||||
val details = fetchDetails(info.key)
|
||||
OfflineVideoInfo(
|
||||
info.key,
|
||||
details.title,
|
||||
list,
|
||||
if (list.isEmpty()) {
|
||||
offlineStorage.find(info.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
} ?: emptyFlow()
|
||||
|
||||
suspend fun remove(video: OfflineVideo) {
|
||||
offlineStorage?.remove(video)
|
||||
}
|
||||
|
||||
private suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
detailsCache[id]?.let { return it }
|
||||
return videoApi.fetchDetails(id).apply { detailsCache[id] = this }
|
||||
}
|
||||
|
||||
private suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
|
||||
linksCache[LinksKey(id, seasonId)]?.let { return it }
|
||||
return videoApi.fetchVideoLinks(id, seasonId).apply {
|
||||
linksCache[LinksKey(id, seasonId)] = this
|
||||
}
|
||||
}
|
||||
|
||||
private data class LinksKey(val id: Long, val sessionId: Long?)
|
||||
}
|
||||
|
||||
data class OfflineVideoInfo(
|
||||
val movieId: Long,
|
||||
val title: String,
|
||||
val seasons: List<SeasonOfflineVideo>,
|
||||
val movieState: OfflineVideo?
|
||||
)
|
||||
|
||||
data class SeasonOfflineVideo(
|
||||
val seasonId: Long,
|
||||
val title: String,
|
||||
val episodes: List<EpisodeOfflineVideo>
|
||||
)
|
||||
|
||||
data class EpisodeOfflineVideo(
|
||||
val episode: Episode,
|
||||
val video: OfflineVideo
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
enum class OsType {
|
||||
JVM,
|
||||
ANDROID,
|
||||
IOS,
|
||||
JS
|
||||
}
|
||||
|
||||
expect fun getCurrentOsType(): OsType
|
||||
|
||||
val isJs: Boolean get() = getCurrentOsType() == OsType.JS
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
interface PendingOperationRepository {
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun query(type: AggregateType): List<OperationInfo>
|
||||
suspend fun insert(operationInfo: OperationInfo)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
data class OperationInfo(
|
||||
val type: AggregateType,
|
||||
val actionType: String,
|
||||
val payload: JsonElement,
|
||||
val id: Long = 0
|
||||
)
|
||||
|
||||
enum class AggregateType {
|
||||
MOVIE_TAG,
|
||||
USER_TAG,
|
||||
SEARCH,
|
||||
RECENTLY_WATCHED,
|
||||
SAVED_MOVIE
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.videoplayer.ExternalPlayer
|
||||
import ru.shadowsparky.videoplayer.PlayerController
|
||||
import ru.shadowsparky.videoplayer.PlayerEvent
|
||||
import ru.shadowsparky.videoplayer.PlayerState
|
||||
import kotlin.math.abs
|
||||
|
||||
@Factory
|
||||
class PlayerInteractor(
|
||||
private val videoApi: VideoApi,
|
||||
private val playerSettingsStorge: PlayerSettingsStorge,
|
||||
private val externalPlayer: ExternalPlayer,
|
||||
val playerController: PlayerController,
|
||||
private val recentlyWatchedRepository: RecentlyWatchedRepository,
|
||||
private val savedTimeRepository: SavedTimeRepository,
|
||||
private val offlineStorage: OfflineStorage?
|
||||
) {
|
||||
private var lastMillis = 0L
|
||||
|
||||
val state: Flow<PlayerState> = playerController.state
|
||||
.onEach {
|
||||
if (abs(it.currentPositionMs - lastMillis) > 5_000) saveTime(it)
|
||||
}
|
||||
val events: Flow<PlayerEvent> = playerController.events
|
||||
val useExternalPlayer = playerSettingsStorge.useExternalPlayer
|
||||
private val playMutex = Mutex()
|
||||
private var cache = Cache()
|
||||
|
||||
val canPlayNextEpisode: Boolean
|
||||
get() {
|
||||
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
|
||||
return cache.episodes?.getOrNull(nextIndex) != null
|
||||
}
|
||||
|
||||
val savedMillis get() = cache.savedMillis ?: 0L
|
||||
|
||||
suspend fun play(
|
||||
movieId: Long?,
|
||||
seasonId: Long?,
|
||||
episodeId: Long?,
|
||||
startTime: Long?,
|
||||
url: String?
|
||||
): Boolean = playMutex.withLock {
|
||||
cache = cache.copy(movieId = movieId, seasonId = seasonId)
|
||||
val offlineUrl = url ?: loadOfflineUrl(movieId, seasonId, episodeId)
|
||||
return if (seasonId == null || episodeId == null) {
|
||||
val url = offlineUrl ?: fetchLinks(movieId, seasonId).files.loadUrl()
|
||||
play(url, startTime)
|
||||
} else {
|
||||
val episodes = loadEpisodes(movieId, seasonId)
|
||||
val index = episodes.indexOfFirst { it.episode == episodeId }
|
||||
if (index == -1) error("Unable to find current episode id")
|
||||
cache = cache.copy(currentEpisodeIndex = index)
|
||||
play(offlineUrl ?: episodes[index].files.loadUrl(), startTime)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadOfflineUrl(
|
||||
movieId: Long?,
|
||||
seasonId: Long?,
|
||||
episodeId: Long?,
|
||||
): String? {
|
||||
movieId ?: return null
|
||||
val isExternalPlayer = useExternalPlayer.firstOrNull() ?: true
|
||||
return if (!isExternalPlayer) {
|
||||
val state = offlineStorage?.find(movieId, seasonId, episodeId)?.state
|
||||
(state as? OfflineState.Available)?.uri
|
||||
} else null
|
||||
}
|
||||
|
||||
suspend fun playNext(): Boolean {
|
||||
if (playMutex.isLocked) return true
|
||||
playMutex.withLock {
|
||||
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
|
||||
val nextEpisode = cache.episodes?.getOrNull(nextIndex) ?: return false
|
||||
cache = cache.copy(currentEpisodeIndex = nextIndex)
|
||||
return play(
|
||||
loadOfflineUrl(
|
||||
cache.movieId ?: return false,
|
||||
cache.seasonId,
|
||||
nextEpisode.episode
|
||||
) ?: nextEpisode.files.loadUrl(), 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveWatched(episodeId: Long) {
|
||||
recentlyWatchedRepository.write(
|
||||
RecentlyWatchedInfo(
|
||||
cache.movieId ?: return,
|
||||
episodeId,
|
||||
cache.seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun release() {
|
||||
playerController.release()
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
playerController.pause()
|
||||
}
|
||||
|
||||
private suspend fun saveTime(state: PlayerState) {
|
||||
with(cache) {
|
||||
movieId?.let {
|
||||
savedTimeRepository.setTime(
|
||||
SavedTimeInfo(
|
||||
movieId,
|
||||
seasonId,
|
||||
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode,
|
||||
state.currentPositionMs
|
||||
)
|
||||
)
|
||||
lastMillis = state.currentPositionMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<File>?.loadUrl(): String {
|
||||
return this?.firstOrNull()?.url ?: error("Unable to load video url")
|
||||
}
|
||||
|
||||
private suspend fun play(url: String, startTime: Long?): Boolean {
|
||||
cache.episodes?.let {
|
||||
cache.currentEpisodeIndex?.let { idx -> saveWatched(it[idx].episode) }
|
||||
}
|
||||
if (useExternalPlayer.firstOrNull() ?: true) {
|
||||
externalPlayer.play(url)
|
||||
return false
|
||||
} else {
|
||||
loadTime()
|
||||
playerController.load(url, startTime ?: state.first().currentPositionMs)
|
||||
playerController.play()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun loadTime() {
|
||||
with(cache) {
|
||||
val millis = movieId?.let {
|
||||
savedTimeRepository.query(
|
||||
movieId,
|
||||
seasonId,
|
||||
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode
|
||||
)
|
||||
}
|
||||
cache = cache.copy(savedMillis = millis)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadEpisodes(movieId: Long?, seasonId: Long): List<Episode> {
|
||||
cache.episodes?.let { return it }
|
||||
checkNotNull(movieId) { "movieId required" }
|
||||
val links = videoApi.fetchVideoLinks(movieId, seasonId)
|
||||
val season = links.seasons?.firstOrNull()
|
||||
return season?.episodes.apply {
|
||||
cache = cache.copy(episodes = this)
|
||||
} ?: error("Unable to load episodes")
|
||||
}
|
||||
|
||||
private suspend fun fetchLinks(movieId: Long?, seasonId: Long?): VideoLinksResponse {
|
||||
cache.response?.let { return it }
|
||||
checkNotNull(movieId) { "movieId required" }
|
||||
return videoApi.fetchVideoLinks(movieId, seasonId).apply {
|
||||
cache = cache.copy(response = this)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Cache(
|
||||
val movieId: Long? = null,
|
||||
val seasonId: Long? = null,
|
||||
val episodes: List<Episode>? = null,
|
||||
val currentEpisodeIndex: Int? = null,
|
||||
val response: VideoLinksResponse? = null,
|
||||
val savedMillis: Long? = null
|
||||
)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PlayerSettingsStorge {
|
||||
val useExternalPlayer: Flow<Boolean?>
|
||||
|
||||
suspend fun setUseExternalPlayer(use: Boolean)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
data class SavedTimeInfo(
|
||||
val movieId: Long,
|
||||
val seasonId: Long?,
|
||||
val episodeId: Long?,
|
||||
val time: Long
|
||||
)
|
||||
|
||||
interface SavedTimeRepository {
|
||||
suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long?
|
||||
suspend fun setTime(time: SavedTimeInfo)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface ServerConfigurationRepository {
|
||||
val serverConfiguration: Flow<ServerConfiguration>
|
||||
|
||||
suspend fun setServerConfiguration(conf: ServerConfiguration)
|
||||
}
|
||||
|
||||
suspend fun ServerConfigurationRepository.getServerConfiguration(): ServerConfiguration {
|
||||
return serverConfiguration.first()
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
interface SyncRepository {
|
||||
suspend fun isSynchronized(type: AggregateType): Boolean
|
||||
suspend fun set(type: AggregateType, state: Boolean)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.seiko.imageloader.LocalImageLoader
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.ui.components.BackButton
|
||||
import ru.shadowsparky.ui.nav.Nav
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.OverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.episode.EpisodesOverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonOverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.player.PlayerScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.auth.AuthScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.pass.EditPasswordScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.register.RegisterScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.serv.EditServScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.tag.TagScreen
|
||||
|
||||
@Composable
|
||||
fun getColorScheme(): ColorScheme {
|
||||
return ru.shadowsparky.ui.theme.getColorScheme()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.imePaddingInternal(): Modifier {
|
||||
return koin.get<UiConfiguration>().imePadding(this)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Nav(rootComponent: RootComponent) {
|
||||
CompositionLocalProvider(
|
||||
LocalImageLoader provides remember { rootComponent.imageLoaderProvider.provide() }
|
||||
) { NavInternal(rootComponent) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavInternal(rootComponent: RootComponent) {
|
||||
Nav(rootComponent) { child, route ->
|
||||
when (child) {
|
||||
is Child.PlayerChild -> PlayerScreen(child.component)
|
||||
is Child.OverviewChild -> OverviewScreen(child.component)
|
||||
is Child.SeasonOverviewChild -> SeasonOverviewScreen(child.component)
|
||||
is Child.EpisodeOverviewChild -> EpisodesOverviewScreen(child.component)
|
||||
is Child.EditServerAddrChild -> EditServScreen(child.component)
|
||||
is Child.TagChild -> TagScreen(child.component)
|
||||
is Child.AuthChild -> AuthScreen(child.component)
|
||||
is Child.RegisterChild -> RegisterScreen(child.component)
|
||||
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
||||
is Child.LoadingChild -> LoadingScreen(child.component)
|
||||
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
||||
is Child.VideosChild,
|
||||
is Child.SearchChild,
|
||||
is Child.SettingsChild,
|
||||
is Child.SavedMovieChild -> {
|
||||
if (child is Child.VideosChild && child.component.search != null) {
|
||||
VideoScreen(child.component)
|
||||
} else {
|
||||
MainScreen(
|
||||
route as Route,
|
||||
child,
|
||||
rootComponent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SimpleAppBar(
|
||||
title: String,
|
||||
doBack: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
doBack?.let { BackButton(it) }
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BaseScreen(
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
snackbarState: SnackbarHostState? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
appBar: @Composable () -> Unit
|
||||
) {
|
||||
ru.shadowsparky.ui.BaseScreen(
|
||||
modifier,
|
||||
scrollBehavior,
|
||||
snackbarState,
|
||||
getColorScheme(),
|
||||
content = content,
|
||||
appBar = { appBar() }
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.home
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.saved_movies
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.search
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.settings
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.star
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.ui.NavigationEntry
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
|
||||
@Composable
|
||||
fun createNavigationList(): List<NavigationEntry<Route>> {
|
||||
val list = listOf(
|
||||
NavigationEntry(
|
||||
Route.default,
|
||||
stringResource(Res.string.recommendations),
|
||||
painterResource(Res.drawable.home)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Bookmark(),
|
||||
stringResource(Res.string.saved_movies),
|
||||
painterResource(Res.drawable.star)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.SearchVideos(),
|
||||
stringResource(Res.string.search),
|
||||
painterResource(Res.drawable.search)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Settings(),
|
||||
stringResource(Res.string.settings),
|
||||
painterResource(Res.drawable.settings)
|
||||
)
|
||||
)
|
||||
return list
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.url_copied
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun getClipboardText(): String {
|
||||
return stringResource(Res.string.url_copied)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.server_not_responding
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.unknown_error
|
||||
import com.arkivanov.essenty.lifecycle.LifecycleOwner
|
||||
import com.arkivanov.essenty.lifecycle.doOnDestroy
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.io.IOException
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
|
||||
fun LifecycleOwner.createComponentScope(onDestroy: suspend () -> Unit = {}): CoroutineScope {
|
||||
val main = koin.getOrNull<DispatcherProvider>()?.main ?: Dispatchers.Main
|
||||
val scope = CoroutineScope(SupervisorJob() + main)
|
||||
lifecycle.doOnDestroy {
|
||||
scope.launch {
|
||||
onDestroy()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
fun createExceptionHandler(
|
||||
scope: CoroutineScope,
|
||||
onError: suspend (String) -> Unit
|
||||
): CoroutineExceptionHandler {
|
||||
return CoroutineExceptionHandler { _, throwable ->
|
||||
scope.launch {
|
||||
koin.getOrNull<Log>()?.e("Error", throwable)
|
||||
when (throwable) {
|
||||
is HttpException -> {
|
||||
onError(throwable.message ?: throwable.httpCode.toString())
|
||||
}
|
||||
|
||||
else -> {
|
||||
val code = when (throwable) {
|
||||
is IOException -> Res.string.server_not_responding
|
||||
else -> Res.string.unknown_error
|
||||
}
|
||||
onError(getString(code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import ru.shadowsparky.ui.BaseScreen
|
||||
import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.USE_EXPERIMENTAL_RAIL
|
||||
import ru.shadowsparky.ui.applyBlurEffect
|
||||
import ru.shadowsparky.ui.currentWindowType
|
||||
import ru.shadowsparky.ui.isCompact
|
||||
import ru.shadowsparky.ui.isExpanded
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
import ru.shadowsparky.ui.theme.ComponentDefaults
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchVideoContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsContent
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
route: Route,
|
||||
child: Child,
|
||||
rootComponent: RootComponent
|
||||
) {
|
||||
if (child is Child.SearchChild) {
|
||||
SearchScreenInternal(child, route, rootComponent)
|
||||
} else {
|
||||
MainScreenInternal(route, child, rootComponent)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchScreenInternal(
|
||||
child: Child.SearchChild,
|
||||
route: Route,
|
||||
rootComponent: RootComponent,
|
||||
) {
|
||||
val component = child.component
|
||||
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val requester by remember { mutableStateOf(FocusRequester()) }
|
||||
val windowType = currentWindowType()
|
||||
val searchAppBar: (@Composable () -> Unit) = {
|
||||
SearchAppBar(
|
||||
textFieldValue = textFieldValue,
|
||||
onDone = { component.done(textFieldValue.text) },
|
||||
onValueChange = { textFieldValue = it },
|
||||
focusRequester = requester
|
||||
)
|
||||
}
|
||||
val blurState = rememberBlurState()
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) searchAppBar()
|
||||
},
|
||||
currentRoute = route,
|
||||
onSelectNavigation = { rootComponent.nav(it, true) },
|
||||
navigationEntries = createNavigationList(),
|
||||
colorScheme = getColorScheme(),
|
||||
scrollBehavior = scrollBehavior
|
||||
) {
|
||||
val content: @Composable () -> Unit = {
|
||||
SearchVideoContent(
|
||||
child.component,
|
||||
it,
|
||||
requester
|
||||
) { textFieldValue = it }
|
||||
}
|
||||
if (windowType.isExpanded && USE_EXPERIMENTAL_RAIL) {
|
||||
Column {
|
||||
searchAppBar()
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreenInternal(route: Route, child: Child, rootComponent: RootComponent) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
val navList = createNavigationList()
|
||||
val windowType = currentWindowType()
|
||||
val blurState = rememberBlurState()
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) {
|
||||
navList.firstOrNull { it.route == route }?.title?.let {
|
||||
LargeTopAppBar(
|
||||
title = { Text(it) },
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier.applyBlurEffect(blurState),
|
||||
colors = ComponentDefaults.topAppBarColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
currentRoute = route,
|
||||
onSelectNavigation = { rootComponent.navReplace(it) },
|
||||
navigationEntries = navList,
|
||||
colorScheme = getColorScheme(),
|
||||
scrollBehavior = scrollBehavior
|
||||
) { padding ->
|
||||
when (child) {
|
||||
is Child.VideosChild -> VideoContent(child.component, padding)
|
||||
is Child.SettingsChild -> SettingsContent(child.component, padding)
|
||||
is Child.SavedMovieChild -> SavedMovieContent(child.component, padding)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.ExperimentalDecomposeApi
|
||||
import com.arkivanov.decompose.router.stack.childStackWebNavigation
|
||||
import com.arkivanov.decompose.router.webhistory.WebNavigation
|
||||
import com.arkivanov.decompose.router.webhistory.WebNavigationOwner
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.KSerializer
|
||||
import org.koin.core.Koin
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.HeathCheck
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
|
||||
@OptIn(ExperimentalDecomposeApi::class)
|
||||
class RootComponent(
|
||||
componentContext: ComponentContext,
|
||||
tokenStorage: TokenStorage,
|
||||
override val initStack: List<Route>,
|
||||
private val healthCheck: HeathCheck,
|
||||
val imageLoaderProvider: ImageLoaderProvider,
|
||||
override val serializer: KSerializer<Route> = Route.serializer(),
|
||||
override val koin: Koin = ru.shadowsparky.koin
|
||||
) : BaseRootComponent<Route>(componentContext), WebNavigationOwner {
|
||||
val isLoggedIn = tokenStorage.token.map {
|
||||
if (it != null) {
|
||||
runCatching { healthCheck.check() }.getOrNull() ?: return@map false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override val webNavigation: WebNavigation<*> =
|
||||
childStackWebNavigation(
|
||||
navigator = nav,
|
||||
stack = childStack,
|
||||
serializer = Route.serializer(),
|
||||
pathMapper = { child ->
|
||||
UrlRoutes.build(child.configuration).path
|
||||
},
|
||||
parametersMapper = { child ->
|
||||
UrlRoutes.build(child.configuration).params
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Factory
|
||||
class RootFactory(
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val heathCheck: HeathCheck,
|
||||
private val imageLoaderProvider: ImageLoaderProvider
|
||||
) {
|
||||
fun create(
|
||||
context: ComponentContext,
|
||||
initStack: Route
|
||||
): RootComponent {
|
||||
return create(context, listOf(initStack))
|
||||
}
|
||||
|
||||
fun create(
|
||||
context: ComponentContext,
|
||||
initStack: List<Route> = listOf(Route.default)
|
||||
): RootComponent {
|
||||
return RootComponent(
|
||||
context,
|
||||
tokenStorage,
|
||||
listOf(Route.Loading(next = initStack.toSet())),
|
||||
heathCheck,
|
||||
imageLoaderProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
interface ShareManager {
|
||||
fun shareDetails(details: VideoDetails)
|
||||
fun canShare(): Boolean
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class UiConfiguration {
|
||||
@Composable
|
||||
fun imePadding(modifier: Modifier): Modifier {
|
||||
return modifier
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import io.ktor.http.Parameters
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.decodeURLPart
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
|
||||
object UrlRoutes {
|
||||
|
||||
object Seg {
|
||||
const val ROOT = RouteIds.ROOT
|
||||
const val VIDEOS = RouteIds.VIDEOS
|
||||
const val SEARCH = RouteIds.SEARCH
|
||||
const val BOOKMARK = RouteIds.BOOKMARK
|
||||
|
||||
const val OVERVIEW = RouteIds.OVERVIEW
|
||||
|
||||
const val PLAY = RouteIds.PLAY
|
||||
|
||||
const val SELECT_SEASON = RouteIds.SELECT_SEASON
|
||||
const val SELECT_EPISODE = RouteIds.SELECT_EPISODE
|
||||
|
||||
const val SETTINGS = RouteIds.SETTINGS
|
||||
const val SERV = RouteIds.SERV
|
||||
const val AUTH = RouteIds.AUTH
|
||||
const val REGISTER = RouteIds.REGISTER
|
||||
const val EDIT_PASSWORD = RouteIds.EDIT_PASSWORD
|
||||
const val TAG_OVERVIEW = RouteIds.TAG_OVERVIEW
|
||||
}
|
||||
|
||||
object Param {
|
||||
const val QUERY = "q"
|
||||
const val START_SECONDS = "start"
|
||||
const val SEASON = "season"
|
||||
const val EPISODE = "episode"
|
||||
}
|
||||
|
||||
data class Built(
|
||||
val path: String,
|
||||
val params: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
fun build(route: Route): Built {
|
||||
val r = rules.firstOrNull { it.matches(route) }
|
||||
?: error("No URL rule for route: $route")
|
||||
return r.build(route)
|
||||
}
|
||||
|
||||
fun parseUrl(url: String, basePath: String = Seg.ROOT): Route? {
|
||||
val u = Url(url)
|
||||
|
||||
val cleanPath = removeBase(normalize(u.encodedPath), basePath)
|
||||
|
||||
val segs = cleanPath
|
||||
.trim('/')
|
||||
.split('/')
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { it.decodeURLPart() }
|
||||
|
||||
val params = u.parameters
|
||||
|
||||
if (segs.isEmpty()) return Route.default
|
||||
|
||||
for (rule in rules) {
|
||||
rule.parse(segs, params)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private interface Rule {
|
||||
fun matches(route: Route): Boolean
|
||||
fun build(route: Route): Built
|
||||
fun parse(segs: List<String>, params: Parameters): Route?
|
||||
}
|
||||
|
||||
private val rules: List<Rule> = listOf(
|
||||
|
||||
// "/" -> Loading
|
||||
rule(
|
||||
match = { it is Route.Loading },
|
||||
build = { Built(Seg.ROOT) },
|
||||
parse = { segs, _ ->
|
||||
if (segs.isEmpty()) Route.Loading() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/videos[?q=...]"
|
||||
rule(
|
||||
match = { it is Route.Videos },
|
||||
build = {
|
||||
val r = it as Route.Videos
|
||||
Built(
|
||||
"/${Seg.VIDEOS}",
|
||||
r.request?.let { q -> mapOf(Param.QUERY to q) } ?: emptyMap())
|
||||
},
|
||||
parse = { segs, params ->
|
||||
if (segs.size == 1 && segs[0] == Seg.VIDEOS) {
|
||||
Route.Videos(request = params[Param.QUERY])
|
||||
} else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/search"
|
||||
rule(
|
||||
match = { it is Route.SearchVideos },
|
||||
build = { Built("/${Seg.SEARCH}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 1 && segs[0] == Seg.SEARCH) Route.SearchVideos() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/saved"
|
||||
rule(
|
||||
match = { it is Route.Bookmark },
|
||||
build = { Built("/${Seg.BOOKMARK}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 1 && segs[0] == Seg.BOOKMARK) Route.Bookmark() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/overview/{id}"
|
||||
rule(
|
||||
match = { it is Route.Overview },
|
||||
build = {
|
||||
val r = it as Route.Overview
|
||||
Built("/${Seg.OVERVIEW}/${r.id}")
|
||||
},
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.OVERVIEW) {
|
||||
segs[1].toLongOrNull()?.let { id -> Route.Overview(id) }
|
||||
} else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/overview/{id}/selectSeason"
|
||||
rule(
|
||||
match = { it is Route.Overview.Season },
|
||||
build = {
|
||||
val r = it as Route.Overview.Season
|
||||
Built("/${Seg.OVERVIEW}/${r.id}/${Seg.SELECT_SEASON}")
|
||||
},
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 3 && segs[0] == Seg.OVERVIEW && segs[2] == Seg.SELECT_SEASON) {
|
||||
segs[1].toLongOrNull()?.let { id -> Route.Overview.Season(id) }
|
||||
} else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/overview/{id}/{selectedSeasonId}/selectEpisode"
|
||||
rule(
|
||||
match = { it is Route.Overview.Episode },
|
||||
build = {
|
||||
val r = it as Route.Overview.Episode
|
||||
Built("/${Seg.OVERVIEW}/${r.id}/${r.selectedSeasonId}/${Seg.SELECT_EPISODE}")
|
||||
},
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 4 && segs[0] == Seg.OVERVIEW && segs[3] == Seg.SELECT_EPISODE) {
|
||||
val id = segs[1].toLongOrNull()
|
||||
val season = segs[2].toLongOrNull()
|
||||
if (id != null && season != null) Route.Overview.Episode(
|
||||
id,
|
||||
selectedSeasonId = season
|
||||
) else null
|
||||
} else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/play/${id}/
|
||||
rule(
|
||||
match = { it is Route.Overview.Player },
|
||||
build = {
|
||||
val r = it as Route.Overview.Player
|
||||
val path = "/${Seg.PLAY}/${r.movieId}"
|
||||
val map = hashMapOf<String, String>()
|
||||
r.seasonId?.let { s -> map[Param.SEASON] = s.toString() }
|
||||
r.episodeId?.let { s -> map[Param.EPISODE] = s.toString() }
|
||||
r.startSeconds?.let { seconds -> map[Param.START_SECONDS] = seconds.toString() }
|
||||
Built(path, map)
|
||||
},
|
||||
parse = { segs, params ->
|
||||
if (segs.isEmpty() || segs[0] != Seg.PLAY) return@rule null
|
||||
if (segs.size != 2) return@rule null
|
||||
|
||||
val movieId = segs[1].toLongOrNull() ?: return@rule null
|
||||
|
||||
val seasonId = params[Param.SEASON]?.toLongOrNull()
|
||||
val episodeId = params[Param.EPISODE]?.toLongOrNull()
|
||||
val start = params[Param.START_SECONDS]?.toLongOrNull()
|
||||
|
||||
Route.Overview.Player(movieId, seasonId, episodeId, start)
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings"
|
||||
rule(
|
||||
match = { it is Route.Settings },
|
||||
build = { Built("/${Seg.SETTINGS}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 1 && segs[0] == Seg.SETTINGS) Route.Settings() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings/serv"
|
||||
rule(
|
||||
match = { it is Route.Settings.EditServ },
|
||||
build = { Built("/${Seg.SETTINGS}/${Seg.SERV}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.SETTINGS && segs[1] == Seg.SERV) Route.Settings.EditServ() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings/auth" (required=true по умолчанию)
|
||||
rule(
|
||||
match = { it is Route.Settings.Auth },
|
||||
build = { Built("/${Seg.SETTINGS}/${Seg.AUTH}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.SETTINGS && segs[1] == Seg.AUTH) {
|
||||
Route.Settings.Auth()
|
||||
} else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings/register"
|
||||
rule(
|
||||
match = { it is Route.Settings.Register },
|
||||
build = { Built("/${Seg.SETTINGS}/${Seg.REGISTER}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.SETTINGS && segs[1] == Seg.REGISTER) Route.Settings.Register() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings/editPassword"
|
||||
rule(
|
||||
match = { it is Route.Settings.EditPassword },
|
||||
build = { Built("/${Seg.SETTINGS}/${Seg.EDIT_PASSWORD}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.SETTINGS && segs[1] == Seg.EDIT_PASSWORD) Route.Settings.EditPassword() else null
|
||||
}
|
||||
),
|
||||
|
||||
// "/settings/tags"
|
||||
rule(
|
||||
match = { it is Route.Settings.TagOverview },
|
||||
build = { Built("/${Seg.SETTINGS}/${Seg.TAG_OVERVIEW}") },
|
||||
parse = { segs, _ ->
|
||||
if (segs.size == 2 && segs[0] == Seg.SETTINGS && segs[1] == Seg.TAG_OVERVIEW) Route.Settings.TagOverview() else null
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
private fun rule(
|
||||
match: (Route) -> Boolean,
|
||||
build: (Route) -> Built,
|
||||
parse: (List<String>, Parameters) -> Route?
|
||||
): Rule = object : Rule {
|
||||
override fun matches(route: Route) = match(route)
|
||||
override fun build(route: Route) = build(route)
|
||||
override fun parse(segs: List<String>, params: Parameters) = parse(segs, params)
|
||||
}
|
||||
|
||||
private fun normalize(path: String): String =
|
||||
path.replace(Regex("/+"), "/")
|
||||
|
||||
private fun removeBase(path: String, base: String): String {
|
||||
val p = normalize(path)
|
||||
val b = normalize(base).trimEnd('/')
|
||||
return if (b.isNotEmpty() && b != "/" && p.startsWith(b)) p.removePrefix(b) else p
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
interface VideoLauncher {
|
||||
suspend fun launch(uri: String)
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.list
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.not_found
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.retry
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.seiko.imageloader.rememberImagePainter
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.applyBlurSource
|
||||
import ru.shadowsparky.ui.components.LazyVerticalGrid
|
||||
import ru.shadowsparky.ui.components.Loading
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import ru.shadowsparky.vbox.shared.presentation.BaseScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch
|
||||
import ru.shadowsparky.vbox.shared.presentation.state.rememberTopAppBarStateByHolder
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoScreen(component: VideoScreenComponent) {
|
||||
val appBarState = rememberTopAppBarStateByHolder(component.topAppBarStateHolder)
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(appBarState)
|
||||
val error by component.errorCode.collectAsState(null)
|
||||
val items by component.items.collectAsState()
|
||||
val blurState = rememberBlurState()
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
content = {
|
||||
VideoScreenContent(
|
||||
it,
|
||||
error,
|
||||
items,
|
||||
onRefresh = { component.load() },
|
||||
onShowOverview = { component.showOverview(it) },
|
||||
emptyResource = Res.string.not_found,
|
||||
onLoad = { component.load() }
|
||||
)
|
||||
},
|
||||
appBar = {
|
||||
val appName = stringResource(Res.string.recommendations)
|
||||
val title = component.search ?: appName
|
||||
AppBarWithSearch(
|
||||
title,
|
||||
isDefault = title == appName,
|
||||
onBack = { component.doBack() }
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoContent(component: VideoScreenComponent, paddingValues: PaddingValues) {
|
||||
val error by component.errorCode.collectAsState(null)
|
||||
val items by component.items.collectAsState()
|
||||
VideoScreenContent(
|
||||
paddingValues,
|
||||
error,
|
||||
items,
|
||||
onRefresh = { component.load() },
|
||||
onShowOverview = { component.showOverview(it) },
|
||||
emptyResource = Res.string.not_found,
|
||||
onLoad = { component.load() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoScreenContent(
|
||||
paddingValues: PaddingValues,
|
||||
error: String?,
|
||||
items: List<VideoItem>?,
|
||||
emptyResource: StringResource,
|
||||
onRefresh: () -> Unit,
|
||||
onShowOverview: (Long) -> Unit,
|
||||
extraFab: (@Composable BoxScope.() -> Unit)? = null,
|
||||
onLoad: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
onLoad()
|
||||
}
|
||||
if (error != null) {
|
||||
ErrorAndRetry(error) {
|
||||
onRefresh()
|
||||
}
|
||||
} else if (items != null) {
|
||||
VideosInfo(items = items, paddingValues, emptyResource, extraFab) {
|
||||
onShowOverview(it.id)
|
||||
}
|
||||
} else {
|
||||
Loading()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideosInfo(
|
||||
items: List<VideoItem>,
|
||||
paddingValues: PaddingValues,
|
||||
emptyResource: StringResource,
|
||||
extraFab: (@Composable BoxScope.() -> Unit)? = null,
|
||||
onClick: (item: VideoItem) -> Unit
|
||||
) {
|
||||
val blur = LocalBlurState.current
|
||||
val gridState = rememberLazyGridState()
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
var fabHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (items.isNotEmpty()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 125.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.applyBlurSource(blur),
|
||||
contentPadding = PaddingValues(
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
bottom = paddingValues.calculateBottomPadding() + fabHeight
|
||||
),
|
||||
state = gridState
|
||||
) {
|
||||
items(items) { item ->
|
||||
Poster(
|
||||
imageUrl = item.poster,
|
||||
label = item.title,
|
||||
onClick = { onClick(item) },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VideosNotFound(emptyResource)
|
||||
}
|
||||
|
||||
val rootBoxScope = this
|
||||
|
||||
extraFab?.let {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
).onGloballyPositioned { coordinates ->
|
||||
fabHeight = with(density) {
|
||||
coordinates.size.height.toDp()
|
||||
}
|
||||
}
|
||||
) { it(rootBoxScope) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Poster(
|
||||
imageUrl: String,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var sizeImage by remember { mutableStateOf(IntSize.Zero) }
|
||||
var focusState by remember { mutableStateOf<FocusState?>(null) }
|
||||
val isFocused = focusState?.isFocused == true
|
||||
|
||||
val gradient = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black),
|
||||
startY = sizeImage.height.toFloat() / 3,
|
||||
endY = sizeImage.height.toFloat()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(0.8f)
|
||||
.onFocusEvent { focusState = it }
|
||||
.border(
|
||||
width = if (isFocused) 6.dp else 0.dp,
|
||||
color = if (isFocused) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
val painter = rememberImagePainter(imageUrl)
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
sizeImage = it.size
|
||||
}.matchParentSize()
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(gradient))
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall.copy(color = Color.White),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorAndRetry(errorMsg: String, retry: () -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = errorMsg,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Button(onClick = { retry() }) {
|
||||
Text(
|
||||
text = stringResource(Res.string.retry),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideosNotFound(emptyResource: StringResource) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(emptyResource),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Loading() {
|
||||
Loading(Modifier.fillMaxSize())
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.list
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
|
||||
import ru.shadowsparky.vbox.shared.presentation.createExceptionHandler
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
import ru.shadowsparky.vbox.shared.presentation.state.TopAppBarStateHolder
|
||||
|
||||
class VideoScreenComponent(
|
||||
private val context: ComponentContext,
|
||||
private val videoApi: VideoApi,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
val topAppBarStateHolder: TopAppBarStateHolder,
|
||||
val search: String? = null,
|
||||
val showOverview: (Long) -> Unit,
|
||||
val doBack: () -> Unit
|
||||
) : ComponentContext by context {
|
||||
private val componentScope = createComponentScope()
|
||||
private val _items = MutableStateFlow<List<VideoItem>?>(null)
|
||||
private val _errorMsg = MutableSharedFlow<String?>()
|
||||
private val exceptionHandler = createExceptionHandler(componentScope) {
|
||||
_errorMsg.emit(it)
|
||||
}
|
||||
val items: StateFlow<List<VideoItem>?> = _items.asStateFlow()
|
||||
val errorCode = _errorMsg.asSharedFlow()
|
||||
|
||||
fun load() {
|
||||
componentScope.launch(dispatcherProvider.main + exceptionHandler) {
|
||||
_errorMsg.emit(null)
|
||||
val rsp = videoApi.fetchNewVideos(search)
|
||||
_items.emit(rsp.items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(RouteIds.VIDEOS)
|
||||
class VideosChildFactory(
|
||||
private val topAppBarStateHolder: TopAppBarStateHolder,
|
||||
private val videoApi: VideoApi,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : ComponentFactory<Child.VideosChild, Route.Videos, Route> {
|
||||
override fun create(
|
||||
route: Route.Videos,
|
||||
child: ComponentContext,
|
||||
root: BaseRootComponent<Route>
|
||||
): Child.VideosChild {
|
||||
val comp = VideoScreenComponent(
|
||||
child,
|
||||
videoApi,
|
||||
dispatcherProvider,
|
||||
topAppBarStateHolder,
|
||||
route.request,
|
||||
showOverview = { root.nav(Route.Overview(it)) },
|
||||
doBack = { root.onBack() }
|
||||
)
|
||||
return Child.VideosChild(comp)
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.loading
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
|
||||
class LoadingComponent(
|
||||
private val context: ComponentContext,
|
||||
val isLoggedIn: Flow<Boolean>,
|
||||
val onLoaded: (isLoggedIn: Boolean) -> Unit
|
||||
) : ComponentContext by context
|
||||
|
||||
@Factory
|
||||
@Named(RouteIds.LOADING)
|
||||
class LoadingComponentFactory(
|
||||
private val tokenStorage: TokenStorage
|
||||
) : ComponentFactory<Child.LoadingChild, Route.Loading, Route> {
|
||||
override fun create(
|
||||
route: Route.Loading,
|
||||
child: ComponentContext,
|
||||
root: BaseRootComponent<Route>
|
||||
): Child.LoadingChild {
|
||||
return Child.LoadingChild(
|
||||
LoadingComponent(
|
||||
child,
|
||||
tokenStorage.token.map { it != null },
|
||||
onLoaded = { isLoggedIn ->
|
||||
if (!isLoggedIn && route.authRequired) {
|
||||
root.navReplace(Route.Settings.Auth(true, next = route.next))
|
||||
} else {
|
||||
root.navReplace(*route.next.toTypedArray())
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.loading
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import ru.shadowsparky.vbox.shared.presentation.BaseScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.Loading
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoadingScreen(component: LoadingComponent) {
|
||||
val isLoggedIn by component.isLoggedIn.collectAsState(null)
|
||||
LaunchedEffect(isLoggedIn) {
|
||||
isLoggedIn?.let { component.onLoaded(it) }
|
||||
}
|
||||
BaseScreen(
|
||||
appBar = {},
|
||||
content = { Loading() }
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.nav
|
||||
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreenComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.OverviewComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.episode.EpisodeOverviewComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonOverviewComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.player.PlayerComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieScreenComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchScreenComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.auth.AuthComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.pass.EditPasswordComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.register.RegisterComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.serv.EditServComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.tag.TagComponent
|
||||
|
||||
sealed class Child : ru.shadowsparky.ui.nav.Child {
|
||||
class VideosChild(val component: VideoScreenComponent) : Child()
|
||||
class SearchChild(val component: SearchScreenComponent) : Child()
|
||||
class OverviewChild(val component: OverviewComponent) : Child()
|
||||
class OfflineVideoChild(val component: OfflineVideoComponent) : Child()
|
||||
class SeasonOverviewChild(val component: SeasonOverviewComponent) : Child()
|
||||
class EpisodeOverviewChild(val component: EpisodeOverviewComponent) : Child()
|
||||
class SettingsChild(val component: SettingsComponent) : Child()
|
||||
class EditServerAddrChild(val component: EditServComponent) : Child()
|
||||
class AuthChild(val component: AuthComponent) : Child()
|
||||
class RegisterChild(val component: RegisterComponent) : Child()
|
||||
class SavedMovieChild(val component: SavedMovieScreenComponent) : Child()
|
||||
class EditPasswordChild(val component: EditPasswordComponent) : Child()
|
||||
class LoadingChild(val component: LoadingComponent) : Child()
|
||||
class PlayerChild(val component: PlayerComponent) : Child()
|
||||
class TagChild(val component: TagComponent) : Child()
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.nav
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.shadowsparky.vbox.shared.domain.isJs
|
||||
|
||||
@Serializable
|
||||
sealed class Route : ru.shadowsparky.ui.nav.Route() {
|
||||
|
||||
@Serializable
|
||||
data class Videos(
|
||||
val request: String? = null,
|
||||
override val routeId: String = RouteIds.VIDEOS
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class SearchVideos(override val routeId: String = RouteIds.SEARCH) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Bookmark(override val routeId: String = RouteIds.BOOKMARK) : Route()
|
||||
|
||||
@Serializable
|
||||
data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Loading(
|
||||
val authRequired: Boolean = isJs,
|
||||
val next: Collection<Route> = listOf(default),
|
||||
override val routeId: String = RouteIds.LOADING
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
class Overview(val id: Long, override val routeId: String = RouteIds.OVERVIEW) : Route() {
|
||||
|
||||
@Serializable
|
||||
class Season(val id: Long, override val routeId: String = RouteIds.SELECT_SEASON) : Route()
|
||||
|
||||
@Serializable
|
||||
class Episode(val id: Long, val selectedSeasonId: Long, override val routeId: String = RouteIds.SELECT_EPISODE) : Route()
|
||||
|
||||
@Serializable
|
||||
class Player(
|
||||
val movieId: Long? = null,
|
||||
val seasonId: Long? = null,
|
||||
val episodeId: Long? = null,
|
||||
val startSeconds: Long? = null,
|
||||
val url: String? = null,
|
||||
override val routeId: String = RouteIds.PLAY
|
||||
) : Route()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Settings(override val routeId: String = RouteIds.SETTINGS) : Route() {
|
||||
|
||||
@Serializable
|
||||
data class EditServ(override val routeId: String = RouteIds.SERV) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Auth(
|
||||
val required: Boolean = isJs,
|
||||
val next: Collection<Route> = listOf(default),
|
||||
override val routeId: String = RouteIds.AUTH
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Register(override val routeId: String = RouteIds.REGISTER) : Route()
|
||||
|
||||
@Serializable
|
||||
data class EditPassword(override val routeId: String = RouteIds.EDIT_PASSWORD) : Route()
|
||||
|
||||
@Serializable
|
||||
data class TagOverview(override val routeId: String = RouteIds.TAG_OVERVIEW) : Route()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default = Videos(null)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.nav
|
||||
|
||||
object RouteIds {
|
||||
const val LOADING = "loading"
|
||||
const val ROOT = "/"
|
||||
const val VIDEOS = "videos"
|
||||
const val SEARCH = "search"
|
||||
const val BOOKMARK = "saved"
|
||||
const val OFFLINE_VIDEO = "offlineVideo"
|
||||
|
||||
const val OVERVIEW = "overview"
|
||||
|
||||
const val PLAY = "play"
|
||||
|
||||
const val SELECT_SEASON = "selectSeason"
|
||||
const val SELECT_EPISODE = "selectEpisode"
|
||||
|
||||
const val SETTINGS = "settings"
|
||||
const val SERV = "serv"
|
||||
const val AUTH = "auth"
|
||||
const val REGISTER = "register"
|
||||
const val EDIT_PASSWORD = "editPassword"
|
||||
const val TAG_OVERVIEW = "tags"
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.offline
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineState
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideoInteractor
|
||||
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
|
||||
class OfflineVideoComponent(
|
||||
private val context: ComponentContext,
|
||||
private val interactor: OfflineVideoInteractor,
|
||||
private val showPlayer: (Route.Overview.Player) -> Unit,
|
||||
val onBack: () -> Unit
|
||||
) : ComponentContext by context {
|
||||
val info = interactor.info
|
||||
private val componentScope = createComponentScope()
|
||||
|
||||
fun play(item: OfflineVideo) {
|
||||
componentScope.launch {
|
||||
if (item.state is OfflineState.Available) {
|
||||
showPlayer(
|
||||
Route.Overview.Player(
|
||||
item.movieId,
|
||||
item.seasonId,
|
||||
item.episodeId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(item: OfflineVideo) {
|
||||
componentScope.launch { interactor.remove(item) }
|
||||
}
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(RouteIds.OFFLINE_VIDEO)
|
||||
class OfflineVideoComponentFactory(
|
||||
private val interactor: OfflineVideoInteractor
|
||||
) : ComponentFactory<Child.OfflineVideoChild, Route.OfflineVideo, Route> {
|
||||
override fun create(
|
||||
route: Route.OfflineVideo,
|
||||
child: ComponentContext,
|
||||
root: BaseRootComponent<Route>
|
||||
): Child.OfflineVideoChild {
|
||||
val comp = OfflineVideoComponent(
|
||||
child,
|
||||
interactor,
|
||||
showPlayer = { root.nav(it) },
|
||||
{ root.onBack() }
|
||||
)
|
||||
return Child.OfflineVideoChild(comp)
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.offline
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.clear_text
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.keyboard_arrow_down
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.keyboard_arrow_up
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_empty
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_state_available
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_state_downloading
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_state_error
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_state_paused
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_state_pending
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_video
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.ui.BaseScreen
|
||||
import ru.shadowsparky.ui.components.ExpressiveLazyColumn
|
||||
import ru.shadowsparky.ui.components.Loading
|
||||
import ru.shadowsparky.vbox.shared.domain.EpisodeOfflineVideo
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineState
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideoInfo
|
||||
import ru.shadowsparky.vbox.shared.presentation.SimpleAppBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OfflineVideoScreen(component: OfflineVideoComponent) {
|
||||
val info by component.info.collectAsState(null)
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
SimpleAppBar(
|
||||
title = stringResource(Res.string.offline_video),
|
||||
doBack = { component.onBack() }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (info == null) {
|
||||
Loading(Modifier.fillMaxSize().padding(padding))
|
||||
} else if (info?.isEmpty() == true) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.offline_empty),
|
||||
Modifier,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OfflineVideoInfoContent(
|
||||
padding,
|
||||
info ?: emptyList(),
|
||||
onVideoClick = { component.play(it) },
|
||||
onDeleteClick = { component.remove(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineVideoInfoContent(
|
||||
contentPadding: PaddingValues,
|
||||
infoList: List<OfflineVideoInfo>,
|
||||
modifier: Modifier = Modifier,
|
||||
onVideoClick: (OfflineVideo) -> Unit = {},
|
||||
onDeleteClick: (OfflineVideo) -> Unit = {},
|
||||
) {
|
||||
ExpressiveLazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
infoList.forEach { info ->
|
||||
section {
|
||||
item(key = info.movieId) {
|
||||
if (info.movieState != null) {
|
||||
EpisodeItem(
|
||||
title = info.title,
|
||||
state = info.movieState.state,
|
||||
onClick = { onVideoClick(info.movieState) },
|
||||
onDeleteClick = { onDeleteClick(info.movieState) },
|
||||
titleStyle = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
} else {
|
||||
var isExpanded by rememberSaveable(info.movieId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
Column {
|
||||
MovieHeader(
|
||||
title = info.title,
|
||||
isExpanded = isExpanded,
|
||||
onExpandClick = { isExpanded = !isExpanded }
|
||||
)
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
info.seasons.forEach { season ->
|
||||
SeasonItem(
|
||||
title = season.title,
|
||||
episodes = season.episodes,
|
||||
onEpisodeClick = onVideoClick,
|
||||
onDeleteClick = onDeleteClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun MovieHeader(
|
||||
title: String,
|
||||
isExpanded: Boolean,
|
||||
onExpandClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onExpandClick)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isExpanded) {
|
||||
Res.drawable.keyboard_arrow_up
|
||||
} else {
|
||||
Res.drawable.keyboard_arrow_down
|
||||
}
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonItem(
|
||||
title: String,
|
||||
episodes: List<EpisodeOfflineVideo>,
|
||||
onEpisodeClick: (OfflineVideo) -> Unit,
|
||||
onDeleteClick: (OfflineVideo) -> Unit,
|
||||
) {
|
||||
var isExpanded by rememberSaveable(title) { mutableStateOf(true) }
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = !isExpanded }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isExpanded) {
|
||||
Res.drawable.keyboard_arrow_up
|
||||
} else {
|
||||
Res.drawable.keyboard_arrow_down
|
||||
}
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column {
|
||||
episodes.forEach { episode ->
|
||||
EpisodeItem(
|
||||
title = episode.episode.title,
|
||||
state = episode.video.state,
|
||||
onClick = { onEpisodeClick(episode.video) },
|
||||
onDeleteClick = { onDeleteClick(episode.video) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeItem(
|
||||
title: String?,
|
||||
state: OfflineState,
|
||||
onClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
titleStyle: TextStyle = MaterialTheme.typography.bodyMedium
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (title != null) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = titleStyle)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OfflineStateText(state = state)
|
||||
}
|
||||
} else {
|
||||
OfflineStateText(state = state)
|
||||
}
|
||||
|
||||
IconButton(onDeleteClick) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.clear_text),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineStateText(state: OfflineState) {
|
||||
val text = when (state) {
|
||||
OfflineState.Pending -> stringResource(Res.string.offline_state_pending)
|
||||
|
||||
is OfflineState.Downloading -> {
|
||||
if (state.isPaused) {
|
||||
stringResource(
|
||||
Res.string.offline_state_paused,
|
||||
state.progress.coerceIn(0, 100)
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
Res.string.offline_state_downloading,
|
||||
state.progress.coerceIn(0, 100)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is OfflineState.Error -> {
|
||||
stringResource(Res.string.offline_state_error, state.msg)
|
||||
}
|
||||
|
||||
is OfflineState.Available -> {
|
||||
stringResource(Res.string.offline_state_available)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.overview
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.essenty.lifecycle.doOnStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.Clip
|
||||
import ru.shadowsparky.domain.ClipboardManager
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.AddOfflineVideoUseCase
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
import ru.shadowsparky.vbox.shared.presentation.ShareManager
|
||||
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
|
||||
import ru.shadowsparky.vbox.shared.presentation.createExceptionHandler
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
|
||||
class OverviewComponent(
|
||||
private val savedMovieRepository: SavedMovieRepository,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val context: ComponentContext,
|
||||
private val id: Long,
|
||||
private val videoApi: VideoApi,
|
||||
val doBack: () -> Unit,
|
||||
val shareManager: ShareManager,
|
||||
private val clipboardManager: ClipboardManager?,
|
||||
private val showSeasons: () -> Unit,
|
||||
private val showPlayer: () -> Unit,
|
||||
private val offlineStorage: OfflineStorage?,
|
||||
private val snapshot: Snapshot = Snapshot(),
|
||||
private val addOfflineVideoUseCase: AddOfflineVideoUseCase,
|
||||
private val userTagRepository: UserTagRepository,
|
||||
private val movieTagRepository: MovieTagRepository,
|
||||
val showTags: () -> Unit
|
||||
) : ComponentContext by context {
|
||||
val allTags = userTagRepository.userTags
|
||||
val movieTags = movieTagRepository.query(id)
|
||||
val hasClipboardSupport = clipboardManager != null
|
||||
val offlineSupport = offlineStorage != null
|
||||
private val componentScope = createComponentScope()
|
||||
|
||||
private val exceptionHandler = createExceptionHandler(componentScope) {
|
||||
snapshot.errorCode.emit(it)
|
||||
}
|
||||
val details = snapshot.details.asStateFlow()
|
||||
val errorCode = snapshot.errorCode.asSharedFlow()
|
||||
val movieLink = snapshot.movieLink.asStateFlow()
|
||||
val movieUrl get() = movieLink.value?.first()?.url
|
||||
val isSaved = savedMovieRepository.isSaved(id)
|
||||
val offlineItem = offlineStorage?.queryByArgs(id)?.map { it.firstOrNull() } ?: flowOf(null)
|
||||
|
||||
init {
|
||||
doOnStart { init() }
|
||||
}
|
||||
|
||||
fun updateTag(tagId: Long, state: Boolean) {
|
||||
exec {
|
||||
if (state) {
|
||||
movieTagRepository.link(id, tagId)
|
||||
} else {
|
||||
movieTagRepository.unlink(id, tagId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
loadDetailsIfNeeded()
|
||||
}
|
||||
|
||||
fun watch() {
|
||||
exec {
|
||||
details.firstOrNull()?.let { d ->
|
||||
if (d.isMovie) showPlayer() else showSeasons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val details = snapshot.details.value ?: return
|
||||
exec { savedMovieRepository.save(details) }
|
||||
}
|
||||
|
||||
fun updateOffline(item: OfflineVideo?) {
|
||||
exec {
|
||||
if (item != null) {
|
||||
offlineStorage?.remove(item)
|
||||
} else {
|
||||
movieUrl?.let {
|
||||
addOfflineVideoUseCase.add(OfflineVideo(null, id), it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveClip(clip: Clip) {
|
||||
exec { clipboardManager?.setText(clip) }
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
val details = snapshot.details.value ?: return
|
||||
exec { savedMovieRepository.remove(details.id) }
|
||||
}
|
||||
|
||||
fun loadDetailsIfNeeded() {
|
||||
if (snapshot.details.value != null) {
|
||||
return
|
||||
}
|
||||
exec {
|
||||
snapshot.errorCode.emit(null)
|
||||
val details = videoApi.fetchDetails(id)
|
||||
if (details.isMovie) {
|
||||
val files = videoApi.fetchVideoLinks(id, null)
|
||||
snapshot.movieLink.emit(files.files)
|
||||
}
|
||||
snapshot.details.emit(details)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exec(callback: suspend () -> Unit): Job {
|
||||
return componentScope.launch(dispatcherProvider.main + exceptionHandler) {
|
||||
snapshot.errorCode.emit(null)
|
||||
callback.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
data class Snapshot(
|
||||
val details: MutableStateFlow<VideoDetails?> = MutableStateFlow(null),
|
||||
val errorCode: MutableSharedFlow<String?> = MutableSharedFlow(),
|
||||
val movieLink: MutableStateFlow<List<File>?> = MutableStateFlow(null)
|
||||
)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(RouteIds.OVERVIEW)
|
||||
class OverviewFactory(
|
||||
private val savedMovieRepository: SavedMovieRepository,
|
||||
private val shareManager: ShareManager,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val videoApi: VideoApi,
|
||||
private val offlineStorage: OfflineStorage?,
|
||||
private val clipboardManager: ClipboardManager?,
|
||||
private val addOfflineVideoUseCase: AddOfflineVideoUseCase,
|
||||
private val userTagRepository: UserTagRepository,
|
||||
private val movieTagRepository: MovieTagRepository
|
||||
) : ComponentFactory<Child.OverviewChild, Route.Overview, Route> {
|
||||
override fun create(
|
||||
route: Route.Overview,
|
||||
child: ComponentContext,
|
||||
root: BaseRootComponent<Route>
|
||||
): Child.OverviewChild {
|
||||
val comp = OverviewComponent(
|
||||
savedMovieRepository,
|
||||
dispatcherProvider,
|
||||
child,
|
||||
route.id,
|
||||
videoApi = videoApi,
|
||||
doBack = { root.onBack() },
|
||||
shareManager = shareManager,
|
||||
showSeasons = { root.nav(Route.Overview.Season(route.id)) },
|
||||
showPlayer = { root.nav(Route.Overview.Player(route.id)) },
|
||||
offlineStorage = offlineStorage,
|
||||
clipboardManager = clipboardManager,
|
||||
addOfflineVideoUseCase = addOfflineVideoUseCase,
|
||||
userTagRepository = userTagRepository,
|
||||
movieTagRepository = movieTagRepository,
|
||||
showTags = { root.nav(Route.Settings.TagOverview()) }
|
||||
)
|
||||
return Child.OverviewChild(comp)
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.overview
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.copy
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.desc
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.details_movie
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.details_serial
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.more
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_remove
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.offline_save
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.share
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.star
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.star_unchecked
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.user_tags
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.watch
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.seiko.imageloader.rememberImagePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.domain.Clip
|
||||
import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.applyBlurEffect
|
||||
import ru.shadowsparky.ui.applyBlurSource
|
||||
import ru.shadowsparky.ui.components.BackButton
|
||||
import ru.shadowsparky.ui.components.LazyColumn
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
import ru.shadowsparky.ui.theme.ComponentDefaults
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.presentation.BaseScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.getClipboardText
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.ErrorAndRetry
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.Loading
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.tag.LinkTagBottomSheet
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OverviewScreen(component: OverviewComponent) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val errorState by component.errorCode.collectAsState(initial = null)
|
||||
val details by component.details.collectAsState()
|
||||
val snackbarState = remember { SnackbarHostState() }
|
||||
val blurState = rememberBlurState()
|
||||
var tagsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
OverviewAppBar(
|
||||
component.offlineSupport,
|
||||
details,
|
||||
component,
|
||||
scrollBehavior,
|
||||
tagsRequest = { tagsBottomSheetVisible = true }
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
snackbarState = snackbarState,
|
||||
content = {
|
||||
when {
|
||||
|
||||
errorState != null -> ErrorAndRetry(
|
||||
errorMsg = errorState!!
|
||||
) { component.loadDetailsIfNeeded() }
|
||||
|
||||
details != null -> DetailsInfo(
|
||||
details = details!!,
|
||||
paddingValues = it,
|
||||
onWatchClicked = { component.watch() }
|
||||
)
|
||||
|
||||
else -> Loading()
|
||||
}
|
||||
if (tagsBottomSheetVisible) {
|
||||
val allTags by component.allTags.collectAsState(null)
|
||||
val movieTags by component.movieTags.collectAsState(null)
|
||||
LinkTagBottomSheet(
|
||||
allTags,
|
||||
movieTags,
|
||||
{ tagsBottomSheetVisible = false },
|
||||
{ component.showTags() },
|
||||
{ id, state -> component.updateTag(id, state) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OverviewAppBar(
|
||||
offlineSupport: Boolean,
|
||||
details: VideoDetails?,
|
||||
component: OverviewComponent,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
tagsRequest: () -> Unit
|
||||
) {
|
||||
val isSaved by component.isSaved.collectAsStateWithLifecycle(null)
|
||||
val offlineItem by component.offlineItem.collectAsStateWithLifecycle(null)
|
||||
val blur = LocalBlurState.current
|
||||
TopAppBar(
|
||||
title = {
|
||||
details?.title?.let {
|
||||
Text(
|
||||
it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { BackButton { component.doBack() } },
|
||||
colors = ComponentDefaults.topAppBarColors(),
|
||||
actions = {
|
||||
details?.let {
|
||||
if (component.shareManager.canShare()) {
|
||||
IconButton(onClick = { component.shareManager.shareDetails(it) }) {
|
||||
Icon(
|
||||
painterResource(Res.drawable.share),
|
||||
contentDescription = stringResource(Res.string.share)
|
||||
)
|
||||
}
|
||||
}
|
||||
isSaved?.let { saved ->
|
||||
IconButton(onClick = if (saved) component::remove else component::save) {
|
||||
if (saved) {
|
||||
Icon(painterResource(Res.drawable.star), null)
|
||||
} else {
|
||||
Icon(painterResource(Res.drawable.star_unchecked), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
AdditionalAppBarMenu(
|
||||
offlineItem != null,
|
||||
component.hasClipboardSupport && it.isMovie,
|
||||
offlineSupport && it.isMovie,
|
||||
isSaved ?: false,
|
||||
{ component.saveClip(it) },
|
||||
component.movieUrl,
|
||||
tagsRequest
|
||||
) { component.updateOffline(offlineItem) }
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier.applyBlurEffect(blur)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdditionalAppBarMenu(
|
||||
isOfflineSaved: Boolean,
|
||||
hasClipboardSupport: Boolean,
|
||||
hasOfflineSupport: Boolean,
|
||||
isSaved: Boolean,
|
||||
onClipboard: (Clip) -> Unit,
|
||||
url: String?,
|
||||
tagsRequest: () -> Unit,
|
||||
offlineRequest: () -> Unit
|
||||
) {
|
||||
if (isOfflineSaved || hasClipboardSupport || isSaved) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(painterResource(Res.drawable.more), null)
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }) {
|
||||
if (hasClipboardSupport && url != null) {
|
||||
val clipboardText = getClipboardText()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.copy)) },
|
||||
onClick = {
|
||||
onClipboard(Clip(clipboardText, url))
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (hasOfflineSupport) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
val res =
|
||||
if (isOfflineSaved) Res.string.offline_remove else Res.string.offline_save
|
||||
Text(stringResource(res))
|
||||
},
|
||||
onClick = {
|
||||
offlineRequest()
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isSaved) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.user_tags)) },
|
||||
onClick = {
|
||||
tagsRequest()
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailsInfo(
|
||||
details: VideoDetails,
|
||||
paddingValues: PaddingValues,
|
||||
onWatchClicked: () -> Unit
|
||||
) {
|
||||
val blur = LocalBlurState.current
|
||||
val showDetails = rememberSaveable { mutableStateOf(false) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.applyBlurSource(blur),
|
||||
contentPadding = paddingValues
|
||||
) {
|
||||
item { OverviewTitle(details) }
|
||||
item { OverviewDescription(details, showDetails) }
|
||||
item {
|
||||
Button(
|
||||
onClick = onWatchClicked,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp)
|
||||
) { Text(stringResource(Res.string.watch)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewDescription(
|
||||
details: VideoDetails,
|
||||
showDetails: MutableState<Boolean>
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.desc),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = details.desc.trim(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = if (showDetails.value) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!showDetails.value) {
|
||||
val textRes = if (details.isMovie) {
|
||||
Res.string.details_movie
|
||||
} else {
|
||||
Res.string.details_serial
|
||||
}
|
||||
Text(
|
||||
text = stringResource(textRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { showDetails.value = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewTitle(details: VideoDetails) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Image(
|
||||
painter = rememberImagePainter(details.poster),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user