From b0af9375923c326a6de796545ccad762cb3be8cf Mon Sep 17 00:00:00 2001 From: Tinsae Date: Mon, 23 Mar 2026 01:14:54 +0100 Subject: [PATCH] First commit --- .gitignore | 32 +++ app/.gitignore | 1 + app/build.gradle.kts | 79 ++++++ app/proguard-rules.pro | 21 ++ app/release/baselineProfiles/0/app-release.dm | Bin 0 -> 14315 bytes app/release/baselineProfiles/1/app-release.dm | Bin 0 -> 14226 bytes app/release/output-metadata.json | 37 +++ app/src/main/AndroidManifest.xml | 35 +++ .../main/java/net/tinsae/tv/MainActivity.kt | 28 ++ .../java/net/tinsae/tv/data/AppDatabase.kt | 28 ++ .../java/net/tinsae/tv/data/ChannelDao.kt | 25 ++ .../java/net/tinsae/tv/data/ChannelEntity.kt | 15 ++ .../tinsae/tv/data/mapper/ChannelMapper.kt | 21 ++ .../main/java/net/tinsae/tv/model/Category.kt | 11 + .../main/java/net/tinsae/tv/model/Channel.kt | 10 + .../java/net/tinsae/tv/network/M3uService.kt | 26 ++ .../java/net/tinsae/tv/parser/M3uParser.kt | 70 +++++ .../net/tinsae/tv/repo/ChannelRepository.kt | 86 ++++++ .../tv/ui/components/category/CategoryCard.kt | 42 +++ .../tv/ui/components/category/CategoryGrid.kt | 74 ++++++ .../tv/ui/components/channel/ChannelCard.kt | 85 ++++++ .../tv/ui/components/channel/ChannelGrid.kt | 79 ++++++ .../tv/ui/components/channel/ChannelRow.kt | 99 +++++++ .../tv/ui/components/common/DigitalClock.kt | 38 +++ .../tv/ui/components/common/SearchBar.kt | 47 ++++ .../tinsae/tv/ui/components/common/Shapes.kt | 58 ++++ .../tv/ui/components/player/ChannelOverlay.kt | 49 ++++ .../tv/ui/components/player/EventHandler.kt | 33 +++ .../components/player/PlaybackErrorMessage.kt | 30 +++ .../ui/components/player/PlayerContainer.kt | 62 +++++ .../tv/ui/components/player/VideoPlayer.kt | 135 ++++++++++ .../net/tinsae/tv/ui/screens/HomeScreen.kt | 94 +++++++ .../net/tinsae/tv/ui/screens/MainScreen.kt | 152 +++++++++++ .../main/java/net/tinsae/tv/ui/theme/Color.kt | 19 ++ .../main/java/net/tinsae/tv/ui/theme/Theme.kt | 23 ++ .../main/java/net/tinsae/tv/ui/theme/Type.kt | 36 +++ .../tv/ui/viewmodel/ChannelViewModel.kt | 118 ++++++++ app/src/main/java/net/tinsae/tv/util/Misc.kt | 20 ++ app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 4 + build.gradle.kts | 7 + gradle.properties | 18 ++ gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 55 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 27 ++ 54 files changed, 2298 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/release/baselineProfiles/0/app-release.dm create mode 100644 app/release/baselineProfiles/1/app-release.dm create mode 100644 app/release/output-metadata.json create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/net/tinsae/tv/MainActivity.kt create mode 100644 app/src/main/java/net/tinsae/tv/data/AppDatabase.kt create mode 100644 app/src/main/java/net/tinsae/tv/data/ChannelDao.kt create mode 100644 app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt create mode 100644 app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt create mode 100644 app/src/main/java/net/tinsae/tv/model/Category.kt create mode 100644 app/src/main/java/net/tinsae/tv/model/Channel.kt create mode 100644 app/src/main/java/net/tinsae/tv/network/M3uService.kt create mode 100644 app/src/main/java/net/tinsae/tv/parser/M3uParser.kt create mode 100644 app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/theme/Color.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/theme/Type.kt create mode 100644 app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt create mode 100644 app/src/main/java/net/tinsae/tv/util/Misc.kt create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22bb44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +*.iml +*.iws +.idea/ +/out/ +!/.idea/assetWizardSettings.xml +!/.idea/navEditor.xml + +.gradle/ +/build/ +/*/build/ + +local.properties +secrets.properties + +*.apk +*.ap_ +*.aab +*.dex + +*.jks +*.keystore + +.externalNativeBuild/ +.cxx/ + +.DS_Store +Thumbs.db + +*.log +captures/ + +*.hprof diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e6fc70a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +android { + namespace = "net.tinsae.tv" + compileSdk = 36 + + defaultConfig { + applicationId = "net.tinsae.tv" + minSdk = 23 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + lint { + disable.add("NullSafeMutableLiveData") + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.tv.foundation) + implementation(libs.androidx.tv.material) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.material3) + + // Media3 + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.hls) + implementation(libs.androidx.media3.session) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.graphics.shapes) + ksp(libs.androidx.room.compiler) + + implementation(libs.retrofit.core) + implementation(libs.retrofit.scalars) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.coil.compose) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000000000000000000000000000000000000..1c31e400910d26195a99775f92377c5f299be8cb GIT binary patch literal 14315 zcmcJ$2T)YevM36I3`iIeCBvvl6v>h^3X(-6XND*sIcE@%I7rSpDPc&G89>62vkW;6 znIY#P%;%hY-n-|$`v1OL_5WYHR;}*cYpvS7cXwB>-rXM+ad4kv;p5|D;pDsOWBpqr zx`!Q{tnEykJUJYk>@BcK`Su6y0W2==w^%s;nkafds{RXpa6d-gckO#X_YKxR=OFK4 z4sI;0$Lu)w`14S##1m#LEE)_J79kec*2LM_+?m77+(XeN%6a9UaDm3cBKjW$-hWU6 z10~QxG+0=u=6|E`{e$9xy{D_JlFt3qCp&ZG8`S7iPV_jrZpmZZ$0enU*yOE}?gmc3 z7V~zqf)yP?hA;mLzR;=s@c0A6!1>8_>Za1iLDRuI)Iw;F&pQ-$4LoYfbR1;&`XJc$ zgx2o66}S&(LTAinkMI9yaYm%9>ndEH2)>fV#eC>syP1Kt^q2vw3Ipl7k?&Eaax_Nj zbN`|K2v6*EYzWn-a$;Vzco137ksUw<5M=gH%v?&l0R3q;2mRQq(y6m@uG3B(hgPFZ|B!DaC;k(lj3Iqoda`M*>}oYP0- z;Y`v>CAyefmK=<&3vm5367I34X&^YKuNKD==*q8?p?~|H?D7GZ@Zfi)Uy(2Oq=A@naCUGII0d5t`!Y!0j!GoBd|h#?)d=d&Dt}gU!HZC{IH;*)UQ~f;8M7 z>)`Y1Z|5WS{XzYZMtwOKHY*#m#b0h6M&}9m7aC$>Fnuj&o3d;f2m9zm@e}t#s71pn z;(!zcBY>7J+~})aKAC`@fiToearbiNOPHXqk-A^;OU0ehWjqn^f`D7Y6ASz0gruw~ zz6&Gn13~G}lxM?HeEkm^g{ddi7G0k_4uW?57~A?_)dko!On%A6v%~mI(xgK0$k4#B zWmxhBi@uR{7dIU%dLARP>GQW)CELqQqbcsj`>dk%q78$%yKp}jvdmWh+j9fw<(>fZ z(VR1&OXQVnp{7T7yf-Oa7RWD26 z>OC3F^z&4uh!k(|)jKLF>Rra!GG*pxs%;C3NZ*|Wa= zc(G>26_1VYpE48oImmWLPMMVF_2qO8%=KMF#MGmCm$*%dt-V`z35SK_9Zf;<=ZkS% zydg<^RiAm9yVp8Kf3Jo+K_pMdx%-rhN`Vgw`3^vwsc8pJrh3N*@jtWC=N#|?whzf> z1#x@)6haeqL2IuvwAoytI<$)P5)$cy0Tx{|&n9klCySRmj>^p9(#dM_1pBQ;(hMU* zmA)+Ic>nFcShC|$ucwSRs`lGojw3v==!-$IVQ&vQ`)Byplq10m z1W02+N^sxiSbMvbMs}A4Ylwj~evl`Jbs-`!g--S8IedR(;H`Py!}^gm0|8~zsx?%u z9#wkiP5bxs_z%vf>xEq+`2LReg4BlXlBi%Jrg2{mlb+Fdna{VcGLaSt9^7kdN2xML z@=)jZ0v0P9?^(P!h(0LFwf^PC_#jSeI`E^UQdHECMXoQEv zAS$)?H@W;KgQeH5a+j%-O`3G>xRIRPyqZwJ@mEM$+KW(`pQ)wpLYgofN^HZnx8G`U zwv46|F7>vfKusSAtAxJJ=&+U5AJvuNcDTmsH3e}r2=uxR<-;Po#d-Cgt0K{_6?;Fu z$Xs;rRx-)nUHmpv0}Y1{$PU@FNFzfe3BG#ei-=^VL2bTgibsTBg)y{rk6x&#mlM4i zF7Wb8nHB{}k5`AnrY*^@(y<6^uXSD_q`_mWZC{%qH18@XhA!jglGMo%e>C=E1dwnppu^z6hzcJ z^;iEa+_R<~}gU$vrA; zeX>P@nlxeBG-7Nx{fPItz-h&}S@w_N;SF+&R%_Z<$;;{C2m>g~I=9QLY%9R8-y`Zg z47uS`9w=Z+v}vA8+}k9v+{f!O?lA8{p1G>jaw*+ZU(m1hiNSA~p1x8W?3s0Y{Px{% zJ%&+zT!+uLf7Cv*2mbC(G;zlz^s#;qSCRVT?grTvM{)ug7S+zItck?_Jk97q`Ksr( zu5E-Ws)ju%Y+TW&pMwcLmr?-8IOKGRm_KbvyXXipjrx>(P88EEN0Gg~)^tLk_N+!A zI@^o-)1OfWu{(#G5nm|BxhShmFiIwUHNUf4ZnL$R@03SI40u%>(etO3KfB*;Nw}Ch z_shJV>RgJx{&1`b?{&A?#r7J&1p0VhI^ zf7nG|Cb>AusvlpTzE)GdbOh7U&-@hWC?l)7M{jN7 z<^)`ac6d?-28$MW+l9s78125|(UQqYIG&5#jQni>Yd4Zvcw-**X=1(IqTD5dD#9q? ztiqTg%@FQb^s3lmNdWZYxsld&xyY7kEwLg1_SJUxEow}XtPnqovXWcFk zFH*^5|y#tn?930jD{8b ze9_2SJ|)VZKE!%zTHCf=6Xx%SKgk)ImF+G(X;pa0+!ldA#Dcp--F;jVe$rIfRq;L1 z$C{QO9x`OR5~Xe6(O+P`j-qoSc=4F=u&E%C35Q!G>(F>rnBW@Y%iW8Z zukngA;)DY3(tGV3DQU(*FO|u@w^O>9RR_{5QwPdCf8ca?bWyX`L!tL4&Vy%Qo8$N}rQv9D%;?q0nU1<_XAs%--L z+_j1o@Jg1#d{3M&lYgGXd@Km^d39#|0u42wV_&T6Rz1MC=ROTAY8qAF{NBR*GYhm6 zHT7a7$$de@*+RRAl`y@`=KX~Vw!YPL%j5VgYF4{%FD#cr`|5mUi(|T&^RTX_jxoYSI$?PqTxB$GtX^>s>77u(oK!W_3jedYel&2 za3Nb4UK)MN0f1kylb!Qzh%wMxTDy28cEAfH_zzO>msy{t7OUwKk-zItcJzT&kG#y5 zMK88f4iCIM2{LO~YZHEq|IC0aS*0;g;aOdy7T~i{`$@$tpv7@MrF&cpji){n`-Y_a z3oa9R;J11X(S~UdFLY zT!=D4AGW3M&}HixK2?b_ozo)u0O`70o@5BKYi9|ewE#zd`OO#8Js(A3+O0m6y70ap z8e22n%1{lk37c2)94-{d+94Ju~n%|y^|v&;3G@ty9_3fp!gsQ;u)qd%Ll4H-_+&n%>zLuURa)V4o3vY z&MgDsly$5CD)d0rI36koDC(AJH%PF(47QM%rNr?tK3L0%;a_hbXnYV|eMv2PmPvVt zjHZlwNQJhg4M+ydf?QT3FKWBv(2yiQz0liQ&(PE0&`VzWtzc1w(+=QypkJa{*Ejz5 z3+}$<5Q*=c;86Zs^K9Bsdd{8(^vXW16niNyilbUKpEEr)?Banfn$KK<-uS~I5bEz5uBC&}ac zP69%ADkWgv3p6suJnGBIC2sCrt5V!5^1J*le&`f!njvU$8kjB20-@{xo$|&k2Xc;v zM4?~47^f)9PY9!%dI4NqkFySgGuY;UsC;7s+2P^bl`-Ped1Ch)BtYYcJa-s56-NEp zsO;Br?iBjI^O{isMptlLK#at-)&SQvP%q%r1eJii86)w`SLh0GnPim_o?AA_^r57H zlPvwH8jh#%p#&EXR%Vrf{mRua) zNuW@#B=eNdvTy zacy%W33={ji#U{NR+hvdq(-G@$C6o4K%I$HE0Y$X3$sf#p10XP=(;=k73Jxg2-5{$ zo#C37Ip0LbKX#re+Wx*9Jty1+aWg%!12{xEuLUj84-^qh_q2wVmnNP$&z3YN z`u(anH<1LFR%sBi;625O!0rw(mE_K${PNO*oEll-!0Cx7$|WqS_GHsq=!Z7i!7yXpHi-hJlH#| z`uN`dY0}&Zj;qD5OX;leq!~P0rB@p_S^e*BHLm&_Bt|c7%{bVu++4Qk{>Zwp*_*Rl z+4AxKH=p#s;)833vY|TA{WJ5N)^8|0^1s3Nf3@5H>7D(*Uu!}%xZA#@n>AaYYN-{w zTOkRAuNvEqln%=QT8aKkW{i2b z4$E3>1xcGdeUr_kkJ3M&fC;Ds0@JSZlUizzRL%UQsb?CP0lal2=}qFIWmGkSs66%J zWwP8QmAS~xqJ92BsMIFilLVH9zgDTjx1~k-3%)h4FDXL;H801NP28V92V5 zF7gtQ@)uNtWYqqSfZqG)a62g{(Kmz6&u0;DtMKXupo+r16y8x3#j^rhzhO9JcPis! z00-anIcdG$J=XB%8MN!0(|R4y=L%nVM2{x8lv0q3GIsCU5y1{%JFj6HI}fBr2G&l* zT*JbB>VJ*jZeP)f9*DhILbVb%L!V8H@t6(tC=?z{%!m$6K}t>Q(^R}~{$8SB;mh!; ziHN58h~MVr&CJpVRiw3tlzA1+`5R&ppJSoMi8n1jN-yETW>!g^BmRw!zcyH(BaTfx5x??OWkR9ADqfp&#_@N zAQg<60fE7L;tg3TuKk#nj4j^_vnZr%Q-$ol%-`VVzBSKVDPem{xl6jUibVj!B@^%B z(5xW~QX7E{zFNWcT|SAjK#sCH?cK#_mG(4x1)XR>Kt6sNK>ODoJGT{!-sUKuI>e*q z(Ll_Z_Q-Ewo3}@01++XOd1vhUS+}I6@p6jr$V0UeB3F_*fiO`mJ{qJJLI^svqt$pm z)cQ|3`=1K*-!ZcPe@t#)i3m(oz6;eXT>5gCb#qeZW$I}f@3uQew~^|HWfx+n^`*YQ zSabX5nq~7=;^9PDtE_+IZ}PjCuTOZR(PJo&7l^*LZjt=H%lF;j>N`u>1Wtw078nxa zrWv8i0Qa6ekfv9`AZngr;5k1$w9;z0#CF^_Iz{)sW@|hI@70Ovpe^Y_!LnsX7{9#~ z1R7Jp51kqS7axs!u;2*;an9yBYj@rItZ5K>H<(v9{s)PbPwqgommt)#T_WT+Y6AKN zk+wEg?b$MSv`*_TEtoQeS-03PJ>L$XGX!|LN(Pi3cPZ-8_m4CW{u!=VDL^)*m%LhFmI`P82?0Nf; z`ztbi8t!!{d$r8n(cqLBU^v+^;`4iQBwneNrElbkS9_b%F+p5aag;bZ^e#2d8dzut zutR@y4ZOx$?nto};kO@Tr#XXeeUp!u&3Q)ZP$V5WQNL(xvqUr?#ho@hU@6Y5j954$ zMYv+LT^2GD;+*#F1Lc`-*Rp6LEqV^dJNG#dbiKeBj|K16kz{@M5o523?_2Z_O)!!4 zjVq9pnx2#e_q)a|j)+LYG`Sp4O|YYqDouj&iGm9NBYi2q!vb#N80JdkRcjE74B@RpKRotT$=tw2_}(Or;^IbKVsHl-sJ>pEK2G++`OAqKv| zaeCTZMatUK0??XG33~cc`|(-uC!(^WP+WteQA_>%XxpQ&L^#;pt{^vV+gD>4Q`JT2 z=1pFOs%k;MDB~CUWkDm}=s(`_fecvy@otD_#Zx|i{QT96k~YD=hi>ia3^M1RX0>#y z8Muwid#n#{9$(N_z3g*butR#Vk>VNmcLsbYiWD10kV<|quitp`#SZ{^>oV}DkPm~<9UV? zfvK-0(iHYl3%-G=ej~$$=|yI499g8z%<6(KlbkZvmKOxA7xP$n%d32GTSi72SWSY%~v`H%4l)h0N!c2Il!?M#IP=vu_n|DP?f^eZv5vfOHh! z3YYHzHTHk7TSdCnUpDP{6zrd3en)TA%{A;=gpoa98@cdsD4a3-#7nLasWqT7=9|By zvr^-K_B7t>FwkA!VVj-TWAFyZDK2yR)sL#oUMmX20iVG=fHelm5oze0*=Fi>z(F@nSAn)!_8koDbvGS!A|< z_aGgoy&(E@+V1LhMD%K(N=(aqcsTt`Z?GEmaN>Fsm439qylEchpR%=-f$pnoMBD)5 z520$WJ?LIic==_e!PwqSZWO%F5%jCy#=2g8XX~Nd^>7B?EbfhDQH#61ro+rvJE4A- z+4NEHR9Mw^znGh?qgQhcQa@m`8m^hvP+FKe?hZLu5yS#-P5W-=e{z=?Sq9CCM|m z#KEJo@`py&*|&C zCz@FmxHrTax(Em;ZHW~3lV7&hoPN_pI{*-r#UiHBldW@Rop>pVR0>9?F)z7AoiVwA~#>R|cE#Cu- z=$~-1zb%n`$(m1gas1U(FbQy_cK(;WwRka0Iox$=OSz-VBbaVG5F~mF?DPuJl1H0) zy6B7k0@S}3B5N6+__tz9`wG#ce6?tF6IRKx|`>?hKY|H%b#j?*m29sX!YKb zi*`p+eOT>KhmhY~U>O5HJ;{K*40`(NsCx*zTQ>pHS`*Jn*^!3i$iWqb}du#Q%h?HzQo# z_t42LGp)B{ON-}sfgRBYONpur-uoXhjWShBFv}6Z-TtnDFXraMRP#H!8~q&!NKste z>q73pVTvL`Yfo%XQJ{PlW;V*MV`E_sn-n{cY1CUf-d`N-g_?>~_ZitwpD*iWr1(cu zZ=VRR9x4miijmttv)cKN9~HA?C*1D0&cNTov>IfK%TowH?M;TgBHrv_DKAS5HI^`^ zt`UEEy(Baf{jPr{AH@${!xv2!8~*epdELgjaKB1nQA86yt83$kn?x@YqViE_9miud zym+4^sO>iJl5R82ELrq+n%1GxqrL?nNUk*hsIO#>3P9^J(^lu`^Buk+@k=3l2rj(1 zW0}clgftcTrT_Q^MK^+lc!^z-{dt(90NWCagl$LB8@X%#@`bZX;9i~k^D`}K8o##> z{Jvd72!{Z^j1qGuLgPaIiRjlW{o>@tk;m*mCG=<8TuAqA3JeBR7@GdHOh3jY)-q!0 ztRgxA5$(D4lqMpxKa}lKF*wq6+knaR2AzYi?AI@Ke@4Q;GG?lQ%eqQWk5AHiWRSW4 zj2H+L;@oue4iDTs+O*QI4}}_Z+U*!qxx#RX5f59KUO|&GLkKg0nZW;6km3JR|Cd&m z@{(p(fN?|HHFHzOCd*aaRs7k%hdXG!D0{cgyk`zv1tN~ZZ|LnA>I|s{C%$At35K*7 zxFs??4kWM|np&i2e)~bH=}t~4h|wQFhu+g=0-sj1gN=!Qx|gY85FK^Qn8_+uiI}gS zhU?1DHcX+8>GKV5zC6CRJ7kCT@NS69UD%y{>0zGCdj3R9MIknD1J^c}V=7b(r@P;ytW30Pb-vecf8|>knI7MW2fwcU z{9Pp1)POh3akJdiUt#u)W{;1_#Z| zdzIzFXDR;(Es z6D(?}MP+~g_l}x=-^NwXzd47;kY>iaX0veoEO=-_{)|%~U`X~hD+*bz1_H|7eX&_X z32&Yl`!J8ditONqnhe@j=hrIH(^ky(tu}4GL^brT7+0>=i<*LAaKYar`|}K*v5RI7 z+X;`-))ReI6nbu1t8}UO>zZKp)Sn~T-d#!W3Tm;K{HX_eMh&o>4$8MrFqLoW&Td2d z5sgtllBgN}P3TGFh-z1LYvPgTUuRCOL;OeRkr+e5^Vk%}r2ud8S`(ZL>5^>bE84#4 zFgc$_3G~4<3$!2Bc|jK|g$MGOR`r*Zlem?p5m##)k~2)`t+ZJR zR>!+`m?wJy-3wj-WVfs8Tv|$`=ZK;^BCoX%g8^P1_VN-^;LPfqg@;?R77}0;arAfX zcmT4-HFQrc>Bjb?p7(=gpI?F-?Wnz_@dLXkD#ji{442zz3A{qx&v0}^e9ph06ksJo z!U(^QZB{B%r3;`$uAk#9sNJfeZ5K)=Hn})21f`f^4feQHXd=2OG1tfWR-N^Xaod~< z$GKlEV*(xDgKi^r$@T=RhoA6&4C90YTt~S$yj2=iovTK-DO_{(*=NpwxSr131*v!* z=e&X_=lhL6w;XU9L;?J@@q9BFCiBnROTu7zT^`&JghDV{O?JEfS`8GdBmqejwz85r zZ^^Nnn3kiSW7cVjU8fg@kj_ywtIVQDTAP_pG@<5tOR+(pd_OF?;U$c)7+ds6h-CJDjw#k7*0wt7@Ck(_woZT zw+Oc<-!J3@hq)xxN{KhGQ9tlru6Lct8oGD7DUd}Zy)t;hqY!@di_W8165YwO+&vt- zT(cs7+c3}PkU_s@Wu4go)ioJ;BUYfFSycEn#x@vm6O~>bcAO(co_bX6TWOxR6PwSG zloOb2H8sWxmCrG0=c{J7^Be@n7Dk2&ubS+Ja4v_?Zgo9^DvklDYSjRD zb_)rG2B#BDuA#n>U*@=_B+P#D4ys}TN4R8JBFE3k?6NRYVPZ5O6TMmjJ(Ntin>Odx zS}yHu)Wd;#1?KC$IB*V0(&0$mNMj(X+sUck_4BWbh#&>u#I1v80Jbw$-_YHW`?ZBgQSO8Hzg z&6AV6Jxht%gY&`GUz>q}@f5afi)icm%avUTi{qE!*6-11<}4JI4s35Qs~I3Gfr+{X zOKPAGmq?2I=Wef|7KF|Mcac}OoxXE0XU&VyeN>BUtf`Zd?coc=?H2@td&53z36`L0 z)WCEr4e^|iaUb5H*FTGnUJ&_uVEAS5T1DT(d#R|_6Y|n0j2CH@H(jZAs#1LA0g4`aukfm@u4!y+RB%=d zQy6L29Sz74!<815*G7Vt?t>uzI;H-HZz}xy!R=zSv}<;?Q+Tp+TGxa4enIZ|O#9VT z&!0EN%IDw9frW9zhPU8+>2WuGp|rirSeI`;3$%3V9>gfM<{gKMCxrW%{hxA#xQwR$ z{=z$VE-m~#wg2Yg6%B}wCVsjinJ%}ArE9R|v;HhAm8#NLd_`*UV49Yw#v!6N*=nW$ zSM%NFB2>T@j~xn1F`*wK3pnapTl$M4C&T2JM;GV;DsY<@B`N`$XX{XvHKkiTmu z!C9mbT(V|VPB0J=`WWKOUMqZAB4AFqk!2%(`#bqO8vP`g)AH`Zpg3(%gWv5JtF!5h zG85R5^Lp&BOt`kcg_6~P7j*xrpWCk=0`7}$kk#glBgal*)aLq%~fL)ks+nY3g;TPF}g%<8|bealr! z?1v#tU*K#IA+zv6ICJv`!RZY2mgog|lT&VNd0Q?3_xYXPc=U@NnT;36cfL3xs?J$= zs8f;o`Q<^NAmBP2ZDkznYnW8r*|J%>dWq;ET6!y~(Jxrledzn9PP_V!P-lzg*w8NAUNsGPE8TS4rTvQc z4>d)#^CMV^TWnFaF`X?q;G_fBUf{}ho=BXtpo1xNMfsPyo~o!gtCOaAPFb$B?mkM6al2|}>hcyD}!y-jg{b^W=(Mm1(I z9aXec)>g=6ZeiLNVZI>Oi#UFd+!SXs!XIj3Vgeehr4G>WcggY_0W?bQ4u8V#vm59! z(e<~`XOZWH;=r_eIhJ)0F!J*H$!RC!Zq zfMnJ<2fqsWtc93(+j%uuyy4GWPhljOcsc=NglcDU)x?~n|CZQHt)M?hKgG7;zZ=W? z%NkE9@_=_UqBJf}WaPsdvIwJvu^L3uGa`MdndA8`TZh;P0;D7C_?Av=&_C}_^)9Bk zYd?S1NAx-EC2BjSR*iaS{-{S)F6Ye*{^cOp4$_hm!0%}%iW8he7Wqe4tE|&0(G*wMH9pKQhwd(w@%Y=o z7vV44fn}nJthX)LJ*vKk54D7HUKp}CntYi9069L`={BhN^3(Bomt_D)N5i5#!3#;h zcPFJh6Y7X|_$9u2kJfpX1Phv^`_%aqHbv7MP7B+LHHgcm--UXz%C!m6ABK3!+Z4PC z%>{|i`AsmLnp}r?*$G>-uW-8Nyn?oJRAuw1F(NyTYNA#d%gOIkDY`8umO=jFcoNKS zRI3I|hqya9)?)3bw^sPA+=#Y$!De%EpzSrV^$BTqq6cfR-&Ci00_N{!=do=VSW@EX zK(ML?SG)(;{7{S=GK@@X>V>-%#`&zo-Q+OfAiIRnwtmZTKBuXoqWm*OhDlv4bL{s= zwyauW!)6ky+0B+9Ky?AKoOMSw+^5iNIYJ^I&+vFOTQkuu<8i$LGOw+?lF%S3jE-|B zCU3@BSv;w3BuC2sY4iu$zQ7sqB88IxPOr7OsTz`^E;#rQJJ-rPp>06&wa(e=ciY%T)&=YJ9B$jHJdTrqUL%t z@O}%+P_XUL236X-zKiF2m=?H8y}k0Jsk2shPZwOlIN~=;vJ0jGlZ;@S>qrC6Vr4!1 zW$A3bGhPMI9DY0d2vXdt@yxT0r(oP1EUHqOo1_RaIGusX^?d~S)ZFG9HfOsgMFD3; zH||~aDd+K=i5{NWI=W_+RPY;55&AkYTJOV$NTi2av7a6%1l$wkh8jO7bvgVb;8K;1 zw$m9!?Mj|&Twg@rCy98AVP`tawDCgEkP%3a3a({c954W6?ztEyy069MmUXZK^eGhY zHLjr4>W!l~UXuymmF*20K6=SiLPo~20z5N#Jvg0RNZ*@9S$lLC-_#2<(Dpu@2q}?4 zB*s;7th~LoSM)43E3;Q6kWsQaym0C}_(3GnMQ^r6v)b!#`)8<4|j*=s#$|Uj7{2zLL^ZRsPJ*_?@LoAOfw?=iUcIR#~ zg0tmeY|CdSO?2GY5ym!F_ zy~ij(I0jhpHisdY#Bet7t}w3C_cXj^GMwLIMm7_PMeGdI=0Pn}-uvXE7P9H7M-MH8 zERJgmMyw|I&;HcCk)s-22J%>a$JvPjx#X$%F6YvpkI_RwU1Y*rK*iBYyiw+9Q##R- z{4Ou)%qS{^@9{_GODjS+V0LA2I`Hd2u5y`^QqDb(k9PB7O8ll*1h<$k!{40et~HcE z+>F!tVhHVKgV{a*@FXzMF`|rZPs28`yW(vaT{x174ABa7f>Szj5<8V69xFa!=fC5i zaVlG0O5znsPkeRyXkIZF0IOhzJRUg!@yzE}kF2Mizq!(i@`El@r7T6Yd6*Kgp$5fe z2BFuTcIg)yfxd^IS7nrz0bpfM?+U5>5kZMOm8BW?n9m>Kvm=7}(O^)EHuQyDOYX>2 zVBG?oe*~F=jC@5#5i2HS6`Y*v7YbJthC)V1oF#6b16^`Kc4o&_m{+;wNkm4m+_HxU z@k$a7cG61AJ`lgzD@=Vjg7>2$0H01&7)MA$OM{J2d%>CKnJ zn!c^A8JNKknAtip%r~SOyj5R9#s-*5*5o9A1hUPv20V+P9&%!_OEdP&Qwx)zGWB4Nfq| z%qNR1-^T8_1nlBBeX8qt-$cx>4od2X`i^?3#i+VoaU=PegZY@uCm$v%=M#RDNSpU$ zJuToYj;Kt+*tqdCmBPE=C1wEVuK2AC$c%p7SlxVC$9(IMQiHboS*6g{r!`eEXSU8( z0LSO>v_Z9Au`(l>*?74_lPPsi{hCZ#^v|`Bi~)Di6>_I|!0+%)38`9QnH-u20}_Cy zGn>WIo1O-;rLNv8IfdM2j@=*fy2fqFRiK^WQc<PGAK||kp7-g@RGn)0?|0t* z)DF7|R6J2$>`xV)UcJ-LCbATzG9-Riv9{2)M3kFkfl!yWVa2?ima%svIqNkep2cL2 zuQh8f3WPk(qoP8}WO3D$I;#FXL8VUd0M7#JX=?D4d;5iS;lI`Hw3I4UdU_0d-gs?< zDvZh1C~ZJ~_$2S}4yBOLZPn~V5Lqcip$UJ>^56YK9|(2;q<`-vdGx-2RK$Mp6ze}L z?EYO>{!wA~zqWtZkNQ(BQG}i*@%&7?G;*#=s_1@IE%d}{+FEN<=vuh z-y{8$?rWq{u#HZ`BalX~@Iy*%pMIPSs(~BdiEdUdcwK4F=9f-nTJ2lSBK+IV#qQ=e z=Rbv|68;?d&1NGYoTfhF5e1s{pEY&sTQPOJmV-d78^hUn7R-m~r4Fk(&ssGtGEo?B zdO>(<6g}1ibkxF%5ICehrKJ>JUtqsUo%%ZEIHsakklUdz{8`Av{Y2=Q zw5V+Y?tZ_SYI+r(=)wURO0g$ZfU*#x_-v7RAHTSg2pS8c4SWXqHC3s2pAng)nJccs zVG%6FaivmV@-nH$mLE`8)~E@;(MLp|_n+^BYDz$kx!7P(ktibo+il$fps&fh?^UDc&51x>}XT19`& ziQzweHso~wsaIv3)IK9+KRlM&LncI$o-lSjqbqoXviBLdx)417L;^d-oGqpPM9Q{b z8iUJpGX5n@q#y6by||wU{PsYnI95)gpVuRlADqC}lFO!ojog-;=*p;4j|-)~{X-1u zN(~HhxzcAEGpD@#LUt7^N4#Q7L40iq2<2`_1#nzXvKc_5rB0p&UsPrH2%{(nuRGZ` z8}zOYi6k%X$AIg8kt zo`lhM$zsKd$BK*x^X}gOHEHl~kOgvCxkD0f3HUqDxaiDOrML2LHKfeQJ&Kqb0XG^# zC30kn3fW=>9wc;U>|r3cgKvyab|tKt!!C2Im#Vrh-qwO5@jN$}KwnAtA24kYwIl)o z4Mi#;5?0TKQun3yb3;fEZ2^8`L26}}Tlwz@YH}<|jI~c>6aW%srV7e^4C6%z-9or8 zIrPk4sn>P`T>2_T{?2R)jGvbH{<n!fj(@f=Hf)!;^hutZTF&>g#f} zf6uD`q(M)^^x$opQ{D2Q$g~cYFQ;jGiGzR&?>lbaXN$M|IkXxiul`H1ju_A7DIuKY zlE+$nBnBS}e-8S^VYo69F#O z&nAp85noB8LyPvVJg8%Vc0cgWuUd+xwG|Y6$CXIulEy&xa|n@Rt7z5QRT z-~U@z&mx27k%ukGY(jLCbe~SdO2>Md<^V7)`P9X)6uwxb5lnVCoEqh6|1}EnV8J)F z5?TcT2$q!NGJY4%pF?z18^h1UtBEpn#qUxlzn06xC0`2v$9amc@<|=zs6f;0545cEdm`!;K`BaaE*i>z z-8#@u#VGXa`e*YGnhMt0AR>&OQUDXthyEQ=?eqlcjVhXXjxq0trs!4_g%(EAO<19? zNa~&^+qkbh?D#0;t()ebST^w#*0E7`o59FB@UA_Oh1f~N#Ur7|&O8mw-~KcFO_i~m zu}HK<^`@^(33#z&W~KA%-Ag-!l+lV!!Ctj z*8o912vn%CgF{}3rVBs4p7S;sPf@9EDDyg(pObw4g*r=i3gk289g`R0^hV4XW%5DN zqj(n55+htb$D6+~5$*J79cVhX0G9SO9hL#t0l6nznOZ@a6mG*}JYRm&ii+?(_6p2f zyj`6;YtAHZ-#6Mvfa|dj?IeZj*8SYM&f3P?iv+0oOW}VUrih&8*h=^BMo@QELcO}& zWNo92(J}T-n#~)^|7tI99r!cN46-^f8(e%GC(T$p=nXD zi*Ckwo+gvT!dLYcmqi%HIN+RfU$#<#!&9;LR0&zn0xfWJt|AgYqZRO_CKg*~x6G~$ zhU)Rt9M62`KfI7wXmI;B7L%;})N>3NGHR-1EwR94)bz{ihP@I8z&FG`1+Dm%Z7zjD{C{^;RF)5>ER63y>Fd1c?EK@!M4XpJ zw1=l=^E`+q};{~o_Z^!TgF2GCKF^|_^^_Rt?!h(UZe6+q5FSWaAk?xKyk-_-eVnDv6B z6c|6Ue(_vyKHK}zC|wg2eX}51d5W!QC_CfyD7qXA_zTGZf}JK4+u8H@N#W7P3+#*Q zwsU(uqPtL`z+lFj^6%Bj5a^qUrkB=rPE2U;z@{jetW5!!7k>hThn@Db;o4$w^DcAWFuU+5_5%Rkwe zwH-0GKB&ru=*Qn9!35x6Kt=EKJ0sKQV19=>m#b1;t?rqhjZc{2euXFSWaSoHb~v^7 zvyS&WjbP`a4TkYbLu$i-9e#`FWPApDrSB9f>!(QQ0>RfNf*QwuA&Xv+#^uY_P z$ke$r*2nLZlneL=mK-F+MEG-Azdoikom{8%zLn>2%)2@hFNE0g{n#4lm1ASSTeGNM z#QYfoaNLmn>9Pn*TiaGmhW}360z)O8R(O7WV?F@ALu+g^+ppA0S6&JnP;or(k(Rcv zQ_A%oa7kvphln#FcmG9+nrw`w6<1xD{0Ve=gZBe>o(o76CD$Dz5UP{5;ox!zgdwU8($8fQdlUP&9)Rf%F5mbyXOh6 zsD2RDI`Yfn7ZyKy+f8o(fDzpMzE~d1#0yGcPNUeIU_=F`S439)9<<-cFn@5}u4YC#Wn&P0Eu6eLlxBq~Eokg*gcfvrA5v*EWM_&8b&DTA6d#lSZ ztxy1)&6AOecC|=92E?jKA1yORD}5U0*;Zs*v>Ww#=|nJ~BvP%tBUQ_nEMfmaC=GW+ zu?n`fNMAzL6^sN8ybykQxB-l+A$2Jh`qYp ze{h}_Y@^XD?0p|o7hx|DC{d0{$i6= z(nmsjZzbHuLsN0J*~hW z3-w#bzy^*Rz*vC^|2NS~voAW>LTPo)00cmKWm^b9412gwZ8^B}whVhk)n1 zXl2J-K#fD6&gLx;v3(+MD<3~|3O{Mf_-$pe?cJc-n)hrpS1(q;g_|>&d7|W-@LMCJ8W@2G;d)jrs~ zXvH^d>dDDNllM{*lkdU@jJiI9cYME`g&{WrN@ZTJk-)5TPY0S;Hn#=61YDOqsa1(y zngp;%RlYooa4C?#eI_;r42K>|T&+9&;xH5(Ga{(cb?h|JIEh=hg5&x;A`Y;VgJpjC z3fSYB1<#l=5wx}KR8KH8zkekXIe)Gea#oT~|4NeaVdMsZJ80>wkBmrDEHwm=1;L!Z zdfH0PLuIX;gs7dP0T}xJNZ1`_>(w4^x4S+O9D5wZ^XFpnNcz0BeeN`|jMN>lKn0+G zB_)KtDJ<3%+3}l`)_$FQ^yyN&0kc6D#UAyd?GoiR5R!C{j;z*Hj3i9fWls?ZB|r}Q z`8Jeg!o-N5tG{}$I24o^w4V_D;OC@E4ZRR!+pK=2I3;CGL}-+Kd#s5f$g z5D+vqP#>{@oCF0tuglom%al%--k8U(h~M-w zuijfg)8h2~wjzBwgbE>Q5OhGT{xAK6rRH2qgRriKVaK^}bWY4G6&y;6+%;aQ01`*+m`pdr%yoO0qmVsA-#{L>3(U8y&Ek zJPs&m^W0Y86x29XlzL2-uTEk>r}N41v5V;wn_K}@Rcb3hPp(?YJc;uz}3Cx^)+kCWt4Xu0LmG%-QVoaP{5fMtG{LePSI& zPzIQe1O1cj*kYan8Ay?`wljD+bo4Gr0@>N)M;`0E@|m9VGiPMH$n+SQ+2&xxP0*a3 z#m}J*=j3RTD>EMB&&|6!8N62ogX$o91cv?l6S<|CRHqfPuR2Zr=iv@57Y?1VEj{UU z0lw;bj&V9CtkK$i0Dht_9t5{a`m^&GX?EWD!eJDmQq3UsZrxpQwgZ;~QgYf$6z;?c zX{|;!4Aedro@x0edgMY2ADRgP0+qA6QwqCjw}>=n2#W=1mBpSg4mb~|z}K_SU*9|P zkXdl2B5$!5D=%b?+oVAlvtJeWM4fv_Tr=adyeg`CH(#52p5Yr`IREDp0U+UasTKVE zbCHIXIO!j&s%Hl6(a51s0o0eTCt?UjU5%(SQwpBHxSI}oTfFt-H{W_B$EUe96FLp> zect9csYg*rC|6w8gel)A)%p0b3L7&|Iz`{zZb9zL;Cul@c@|PCIoaGU=r17FNyir* z+d)A6mNX}b^dn%fKecAnw-=l-lIlYaT`A+ONR^?8?r`R%vn0j|mN)=|pH(9%eqr2l znFMWd3bZ{n{aCZiH(s>7cI%AFJ^6Q|g17m8Yy>;+T%E!~^=?`_?)HA-7BN^@a^*a@ zTv*S3-PyEcFhPg_F#nQm+5Us!ZIYsiR`1@qP{oJjSsl2%o$ng@ux5+-d;a2 z(6e!76fn>9gZkD*7`>&&R44#n3mo3hZXmsCwA()jv$SpO0NZseEFDtHePbDx7O0b? z1Iz3Bz>Pmfeu?TNeDZUcVI9fe`1sF5c^x61ujK3~ zHzJ@8wufwe6L9d9Nh{fD>ge(744T7?*qR0TO=CG>nmGgwQGbV4GEO0p%?DU4_ zKQn|uM^QnMSsgJuSJtmrV2l0jZbns^I$LK?r_bzPY!W8y;bC!fX@s}(XU;!1SYlg$ zcG1$|wUejc$QxWQ0A^zSHlLohkTpnOH#bXGG)h-0wfXIr8hI?rVPk1Dhi9Pt6|&gUG181G+Rg|!2ZVOej_>r z=?m#atMZTf8DM?nN(O*Cb@RqWMsK7)o*W8&j}Om#Q%Ym)&^jqECKdU0+uO17D7o|( zdIaFYZ!Zy5uK$B~B*nrkz$|AR6XosP7`&ajGAdEBe^l)f6}F$#!Cjz7OD-}+3*{wK z@Q_BKgq8#~%1B^?HF#X`s3o{$>Y=M@M1x>f)J%F~2St~kHTnCdX;ReA+f-?Y|3Z7? zFD$aFjeM%xOA>BrKmPzj?%>+tpe?mm$%yn@k+chQsV~Ns{Uu_nIxKRsfV0yZXV7n! z3P61BGSe7Uy&{BL^Y=TglCku(y9JI0`R~yNrSC5tZSHb2 z$G0XzmwB8mI_ggAO~Ki<6BIs`fKE@dy8ML!*Y%+bKE0%cDzMjMKTBXR#iNE5OU7`Ro4W?{V3 zmQL*C0^p1X5L4m<=IXU(+*vN#9>R){F=@k{G)*@E)8+aNyCF5$#AlT$W+Wdx*Ly?Q z|Hn@xU>TQDyI&?W0E#De5lL*>`ySiCU_GAKT?r=rz_=8Kl%yD3;GdJ|#Ru&?m%`5D zpLT_DRai2U)6M;!KSzz}V5`fI4rc z&PGnYApVU=YK&Y!s<>^P<$1BR_-XPew|Cm7+q{R6l%#vE>^|Z7gGaV zl+Wf5vj)XbdagyBN9FXSng=%(ky=n$_BJSTAmvm@b^`|F}#-nKg{b636NTGMq zJ503ud^&ZhybZ`{fgEt%$1^9XE3hWrCdC9HspVD{W2DnlP=Q-OD=V=vLUvQi0%mc( zozA_t@B~v2YT#K{j9B2ADkSmROBN6!1#h`B36Xmm5(a)OQmSA79g34)7dA60xSmVi zQq~}GbpktUdlyW091MN}?$3OcGul|V7cCY53(d*H$poW4;7))rHXmIN&z0M zVcnnmtRQAE!!DDJVb0{_yUIH22&b$Qlv2zN(kW3MH zaBgMY>ldqn)b;yQC-FoXgobD+6E-rzFQ--=OUt5e&KQ|XE(Ir7%;z!aHi@%9DaT$8cKpn6J8`cl?mqIz*xh~cHf`oDi}J-1arZ*dR{i*TITZ}=Q#$;8*VM0 zZz8C+Ryk*ZoEJ; zBpA3V7)h~{SC8yXg+a#tz@Z9rG~jL;kS#yi;{8m4sh-p(GwcWRc$dZuJf|wTfCh*& z{g%lyLZ3zX`oKooU(izdj(7KdLA9>7JTTt#fz!vnQx96JMsG+R*^ErTo_a}mi`t!m zN&UG2BWFl($oL$sgK7Qp3o*B9 zpI=6Jy(1)-8O%&C2kErJ>S|jNRNHFFV({TpU&iiTDuh)v{{RCIFuwMEU~Zk89b5S5 zADg!Rcj%~hF$1S=npX;`mU2i9IhHCR8YRA~ z-w;`gECC{AEm`}6I)7c2VR}Eor=dd*j&KE2j{pf+cR!mY}%QGHk z+i?;;Jdt^_@5p9&>{2pyJArbte?gem2`459LcYc-Bv!La#r&AVqUWKZ)m6^-Wu@W2 zTj?|T`3SxfsRBm)*_B!j24B1*zAck`N4rrdoWdchmce6i4NXh6jNRb4EQgb-%+rrt z-!22~u5|pgzHn=8@mDNby5AQL4gb8>9P>L>==&U`i4%S95vVm=R3y;ttvt2V<#y8} z@O7&YKggHjf=b%3Nps?wokVF#_I6F1WkCuasXEVTw_-aGg9>P(v+0VE?e^%6pn`cd zk|Y$%Zv}IbIw-G`68RnINmQpb5cFW(J*F{o)!!a2EioS2$Vxaml0D78LRcJ=6Xh@jMSs2DbWhNNrI^@Cp)q!1|_bbH^Ovd&5eF4wMXEi#7{z`3H+ zISb$TP7j|{+{`FF0fN(}Uq;ZhMGl-~$A~u>{3mi4>O>$W0CR3x&6QRay!)(h1Z{$z zn4mM)FDcG>1?g4oADYB$&ev#p9{mKLPPABjQIb4a^&d5s9ytL>ZRSUipB`WxJ6ED| z`-3x!T(W^R(CZbajvp6Cf@>}&P3%efRnC)PEeW%x)%*?Ywd-VZxqwh=Az>#hmvh?PCUWd)h$1SWK=krB8=@VX{jmTF0+5xVC zEb%5EXcqg5zrDBjszNv-syN#@4K#fxKy=42Ev&IyJ9Zi|g<7vKNbIN-&|nB`v@>lY zyB47kZk4C}vh=_z&hRrA2)$6Y>b|t#{Y}5lVsgf`~@o&uo)7vTrZX6SFN||daO}eZZb#_hu1qEw@ zW8Wh<%AcCfWgu4JC3K&?A1I?k18Q*lLyk%tp1#$hETb29hnzj}Hl6TkYr2F>1|Lk; z{5mMYyMi~8J zRFI2&wX2@|vVk`|cPk5jKnjJ!Y99L7`tmgy`sXV(Q9nhcNAY|Yw6P1EYFE({lqR4a zXpjcLeIe}4vzKi^qdRkUBdYV4FLV+a5kOnEJOLowU3eo1%4uJv324s+F z|KMhs-$bvj_l>2!``tk*XrYa#0cafw6B`uoNq7Kl*+uvi3Xhj>K|7=$v*F2%?>f8~kem2YJde%>qrT&RkSl$2JxaV@b=p(R&%%pyo<1w% z=nCUKOOA1Qr^xLoaLakG7=dh?O=bQm_a85e%p`1{W&OD99j7sQGY;w$4<5?4w>bv^ zL4j3m9_w(fc;n;uOXer@%rw&X4BCdr(DZT&9#=W4%EnKc}$z{5Fz$z7VN4Z<+Y-}=nr3D z@;|z1kvj2N+1k3?RH#L;+uzY>LR#Nk_KqzBI+pddUYWFGQM@Zv0lRsDwYEuV$(Qb% zdod{IK$!J%T9+xT!8)NszhynwM}+^5q26y*2)G)$D+CB?d{`G5K|(^>u~-*_98_33 ze5&@qoh6plxPb+7!fO3Y_h(&=?vF1wiN$+hjRVLg%K3#d8Q@6@sPOHhrX?iT6C(Xv z$BJGapJPP30h2>zADv!+2&tINW-s|48MdruamCviLS$pMbv)zoiKXSUuAjOWkHW7S zUbI@1BYewKSuBx_U=O46z8&?fWj4}%-|R-lUA?n?mwEajk?wYwU(4uY5_9JFMv?c? z&jmglIJBT_37%RzN#F(NP)3LxeuNYVmFcR?YzUH#b+5#`49$a~3O}6klNqkVhB%S^l z#KaVAv-!wUd*fR}k7xA!f_pEUlg7)X=tT6d3x#;{#SM`RJSZr2*v*?ycLSfhJucev zV75+93^r{r7-BdW9Q`noa&vY5xRZ6AR?zyli@R!zWBAAD`Os}|scrfiZ-CedeE%Wc zR(y2Im>oU$PN8NHrv%3r2`lyKR!#FJpEzd+8TVp7Yii^F;Q9DQ*I!7GdrS@+TD=(Bx z^inoSjApw7qI2PaZBFH)vkqbHJ!c}&jGo13j`TvdVG=u9!uF=JJnV!^Z(llwoy~P; zaZ|3R*Vs0VI6lRmsQG`KyAO|+R1Q0Pl+Fm)UkEsx7m%j)$YzOoUA9@!^)t#k(n;Do zTTDUk%3I12GI#!ncmsBsr;cqfK7bA1znLp%WZ8->beemfedc65wugCpZ1XVl#?QcC zGDxD46gk>D-WYb{`}4u75uQgFgBNHiOsYbc&xZ+3L2gkfxn*X^UpZFDb;2G-y%{xj zKNBPOVT|qN*mI_Z7K5)x=N5H%%a&h`b_)x+2YaYzn3nJr01Z4z-@WV_9zYZp(hDuX z{s#CK?_^tPRKOkD7tCilJ#(>yOq8x|0a@B69u}kD=mm0^sZm5L^L(;9+nVNt)$UAT zDu6{COVsD~RC0^^VpWYg9HaFVoY6&Nav36oVNMIyOo1ZkvKtY3eb$$-TQhMFHP|4w*c8}_gFdWYl*lz{ z*_A9oP)@$PVkff3MB1cfDKa(rT^D3CcH^=gB)c{Y7@5%Z7RRhD5cpxGkZn?RGTnPLeyH zLwz-5WSPc*w`P>!g;dt8=%+M9<*v8d#ssdWzaAyFyb9*A?iQP3 zVdTCxbB+vy;i3{8SRHwi9_mAt>E~wW2?5{>V7qH;&xL()3Nd8^Q%VeH;eB3Cukxv{ zhq1d3h3^QwA5D&H^2PKFN>B^s(u zYaPcvve=cIJxEF&ysz{NxY~a` z5S*1CaP^~yJIQ*e)tS>i+h8M!B=ZnXYNAwSK{?xV~e>*SI zG{o18Zt(zaJ1BWbLu>a>PLG+(MBi#?Y~a}u0-G*eHpxsb1`lS3vNeraAQjEBHL+eo z0A?2mY-BLi4e;ZxP^~g!>ZY6rlQqh@1gumZzCF*r^Q~NbgE_7s#C}L^$@Rqmm)8_3 z+on-+l;Y0^g{3aFXICeGdn+hxq2CLxL5O97n zTAw*TUz*#uma2ExQ-Q6IR&eoI-sWxpp`P6+aHE+5TI})pu)Ak^JK~kO=)7y>I7}Fw z1~G(()`3=%d^Swh#2+ozI>gWKU-ICl(p{-I_e+9wy$|m)roj7h#WWKubc56@Ee98* zm4<=HY{D$|B{Qag!}I6Bj9#Llktjw}gE-#yI;&+}8`9oZX@nS~o5T2je6d1nO1D*x zta-*+TlPAnJkcx`yZy#A;u=pGb5 z3(sHM2sy>|>AkC*;m3PJLD+{nr3Akz`81WRot&*-%=>oer}UDtGbyP6)U`WaSH*IChIrgmpmyHlM1HB$1R>_|z5q1&o9K`K=NF9j}uxQcUemexHV zMXQUd#yX@(x`mCuerH^JJz$R7VYA-xXK@yW0OXt)QZm`{ai(=o1tMl+jE@V0?> zQOfcm?(Sc@j+(Sm`O0C_O7_ho)E?V>qz%E6^qZKn_LB6Nr=mgy<>%6o( z)gP*Bfgt%Rr_6vTxb&#&1o@zvGH`Nj39&aHu3jl+y%7@#0>L?&x;A^GSiIdF=B&g} zcE*8Vh3E7O_wFSV44x5K(LeU$D9oIY(*9mKLooC`BsS6mzfA1nSECj22BK67d#Za* za{2TIGH{J<`4G>$QClq-&(D^;Y$lL=5)UwKlBC!jNlX$dKf3zxXOnG!H|>YdKqEft zro}$Uz&&BWpo-fe*)Q7XQJ=D)d8U_aMel6%Uv8#K+B)al0L|NDX^+?dAlz>Fl!QA!1@3iKAR8e}+ zvb53N3VPq)YWdAZdxZb_$+BAwW_Xirz=e9?}0c&ubfk!==t7QT*DSk!B_AEF$B%uNA)3--|@(^+{Z&BhGEn? z3`z;?9$&AYtcuRxE$p7{2mC6!n8@cnT0^Az9t6 z=6^Q2)|io|U8zp0!eWU;#sL;uk<%BGE zgNe&6mm~S_`dL@*&;38#-6=}MN%7v^lv>(d4%SQ69v3}sr2Z$t^}pVK1-T^$z29z- zKkY=F2!Qgpmw9-Pde=XVEn3jkvQuNP-(%E(cIVLBo$qlOt7RG{dU0PGZ3>rWd9^P) zc#x#QF^=1a2sP5v(%x*+@HEx850=8_%W1{yBYQbMOmVX4KlibbcI@pHLpw_Iu}X8& z8Gf77{X?xX15qPu-A9yiAC)U%;!Gbd@(#-hu;jThi?gjwFW=N@zi@!0<9#6)I@C#k zL)Gqx-|e_qJVnTR*>gGzx|C3TPkYCU5V59^?Q)XTgqDrW9PXt8H&QQ0CEdxvN~9kn zGCQN!k_zwnx9UAjM7{859f;*Bw>({(1RplNubO>Xgv=U@B=+fL%V@X%Q+MF+oUzkl zA_RrW`)6ZQ=!kmr`A0b)aazBb-?*z|3~^m%AYT(8Cwc4k3fHPxB~wm zp&6+lSyiUye!-N+vM5sLR{7V-Ja;`nKWD=k$dvHPDFDVZm(Wc38tr}J?Xp-o8gXzf z=s)t*e2%rVMD1y=%epsy@k_m2?1s{TY~f6?b)Dsfd=i#l^W9H{hDrXu%O&7OfN&#y zC9le*K0L2S=f!Xx%Z>RyeEw(t5EsXgH4=a}h!Y4uW=a)Rd6yL5ikx~cDBvcPAV^J^>s&oEUB#oc4SFbo?<;ai)}cRK9)r0xC!}sDMlN z_-!}|#l08LYToljuxbOR2`9`PMpV;(-?!4iZ=r?)8Mil=tX!T} zQE}H=-NLvYezHE95|cmPw>7^RQ8_CcXDXf&|C%KP<+@%ozhzt4ewds!3#{i!GCVBg z4)IbTs9~n72pi*vW1;E}1qcuLrb@vk__eTZSvtu0<&ziCE%3@N@p}i}QU4^pCp8h` zvt>oW@la813rP5$?84#WUAtu2VL40zokiAE^83)+@YHbK=oSlPK#aoJyxu4LH8%54 z;&tsLxUyzK_{{<7Ef=FY;I_7nh_I}T-KcL~E)Qt$?LQdy*|e<$GKGF-Hv238TQa23 z8HmSo$EZj6@8wma4@eUFyzj)7j{9`B;xhw2uL25lsxNIG>AgOV7+r~;NZ-(!IdEWd zdXO{KxRee#tUZ*@Xm!L-QFekWpB3#+1gb>`Asj-LM@NU0&El&qbyJ_l?4`{Jke>!M($^BG^$&^~ z!0JEV>Z(?KfI>~6+L_04@5hJ z6w`k-dF*f0P{F-Nh4b%@aQ|v6|8Ru+kKte4;{J2Xf9>@jw)F4e{IA{pYfC2$mHPz$ QsJ{32^ZJ_-3jag;A3**uX#fBK literal 0 HcmV?d00001 diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..45037d5 --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "net.tinsae.tv", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 23 +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..713b1cd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/MainActivity.kt b/app/src/main/java/net/tinsae/tv/MainActivity.kt new file mode 100644 index 0000000..158562c --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/MainActivity.kt @@ -0,0 +1,28 @@ +package net.tinsae.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatDelegate +import androidx.tv.material3.ExperimentalTvMaterial3Api +import net.tinsae.tv.ui.screens.MainScreen +import net.tinsae.tv.ui.theme.TVTheme + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalTvMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Ensure the app starts in dark mode + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + + // Ensure the window background is transparent to allow SurfaceView (Video) + // to be visible through the UI layers. + window.setBackgroundDrawableResource(android.R.color.transparent) + + setContent { + TVTheme { + MainScreen() + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt b/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt new file mode 100644 index 0000000..f279725 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt @@ -0,0 +1,28 @@ +package net.tinsae.tv.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [ChannelEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun channelDao(): ChannelDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "tv_database" + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt b/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt new file mode 100644 index 0000000..a49e913 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt @@ -0,0 +1,25 @@ +package net.tinsae.tv.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChannelDao { + @Query("SELECT * FROM channels WHERE isFavorite = 1") + fun getFavorites(): Flow> + + @Query("SELECT url FROM channels WHERE isFavorite = 1") + suspend fun getFavoriteUrls(): List + + @Query("SELECT * FROM channels WHERE lastPlayedTimestamp > 0 ORDER BY lastPlayedTimestamp DESC LIMIT 20") + fun getLastPlayed(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChannel(channel: ChannelEntity) + + @Update + suspend fun updateChannel(channel: ChannelEntity) + + @Query("SELECT * FROM channels WHERE url = :url") + suspend fun getChannelByUrl(url: String): ChannelEntity? +} diff --git a/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt b/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt new file mode 100644 index 0000000..30e5271 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt @@ -0,0 +1,15 @@ +package net.tinsae.tv.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "channels") +data class ChannelEntity( + @PrimaryKey val url: String, + val name: String, + val logoUrl: String?, + val group: String?, + val id: String?, + val isFavorite: Boolean = false, + val lastPlayedTimestamp: Long = 0 +) diff --git a/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt b/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt new file mode 100644 index 0000000..902e216 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt @@ -0,0 +1,21 @@ +package net.tinsae.tv.data.mapper + +import net.tinsae.tv.data.ChannelEntity +import net.tinsae.tv.model.Channel + +fun Channel.toEntity() = ChannelEntity( + url = url, + name = name, + logoUrl = logoUrl, + group = group, + id = id +) + +fun ChannelEntity.toChannel(forceFavorite: Boolean = false) = Channel( + name = name, + url = url, + logoUrl = logoUrl, + group = group, + id = id, + isFavorite = forceFavorite || isFavorite +) diff --git a/app/src/main/java/net/tinsae/tv/model/Category.kt b/app/src/main/java/net/tinsae/tv/model/Category.kt new file mode 100644 index 0000000..9e941ce --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/model/Category.kt @@ -0,0 +1,11 @@ +package net.tinsae.tv.model + +data class Category( + val name: String, + val url: String, + val type: CategoryType +) + +enum class CategoryType { + COUNTRY, LANGUAGE, GENRE, FAVORITES, LAST_PLAYED, CHANNEL_LIST +} diff --git a/app/src/main/java/net/tinsae/tv/model/Channel.kt b/app/src/main/java/net/tinsae/tv/model/Channel.kt new file mode 100644 index 0000000..ad6e139 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/model/Channel.kt @@ -0,0 +1,10 @@ +package net.tinsae.tv.model + +data class Channel( + val name: String, + val url: String, + val logoUrl: String? = null, + val group: String? = null, + val id: String? = null, + val isFavorite: Boolean = false +) diff --git a/app/src/main/java/net/tinsae/tv/network/M3uService.kt b/app/src/main/java/net/tinsae/tv/network/M3uService.kt new file mode 100644 index 0000000..863127e --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/network/M3uService.kt @@ -0,0 +1,26 @@ +package net.tinsae.tv.network + +import retrofit2.Retrofit +import retrofit2.converter.scalars.ScalarsConverterFactory +import retrofit2.http.GET +import retrofit2.http.Url + +interface M3uService { + @GET + suspend fun fetchM3u(@Url url: String): String + + companion object { + private var instance: M3uService? = null + + fun getInstance(): M3uService { + if (instance == null) { + val retrofit = Retrofit.Builder() + .baseUrl("https://example.com/") // Base URL is required but overridden by @Url + .addConverterFactory(ScalarsConverterFactory.create()) + .build() + instance = retrofit.create(M3uService::class.java) + } + return instance!! + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt b/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt new file mode 100644 index 0000000..771c8b3 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt @@ -0,0 +1,70 @@ +package net.tinsae.tv.parser + +import net.tinsae.tv.model.Channel + +object M3uParser { + fun parse(content: String): List { + val channels = mutableListOf() + val lines = content.lines() + + var currentName = "" + var currentLogo: String? = null + var currentGroup: String? = null + var currentId: String? = null + + for (line in lines) { + val trimmedLine = line.trim() + if (trimmedLine.startsWith("#EXTINF:", ignoreCase = true)) { + // Reset metadata for a new entry + currentName = "" + currentLogo = null + currentGroup = null + currentId = null + + val metadata = trimmedLine.substring(8) // After "#EXTINF:" + + // M3U standard: #EXTINF:duration attributes,name + // We look for the last comma to separate attributes from name + val lastCommaIndex = metadata.lastIndexOf(',') + if (lastCommaIndex != -1) { + currentName = metadata.substring(lastCommaIndex + 1).trim() + val attributesPart = metadata.substring(0, lastCommaIndex) + + currentId = extractAttribute(attributesPart, "tvg-id") + currentLogo = extractAttribute(attributesPart, "tvg-logo") + currentGroup = extractAttribute(attributesPart, "group-title") + } else { + // Fallback for malformed or simple #EXTINF lines + // Try to skip duration and take the rest as name + val parts = metadata.split(Regex("\\s+"), limit = 2) + if (parts.size >= 2) { + currentName = parts[1].trim() + } else { + currentName = metadata.trim() + } + } + } else if (trimmedLine.isNotEmpty() && !trimmedLine.startsWith("#")) { + // This line is the URL + if (currentName.isNotEmpty()) { + channels.add( + Channel( + name = currentName, + url = trimmedLine, + logoUrl = currentLogo, + group = currentGroup, + id = currentId + ) + ) + // Reset name after adding to avoid duplicates if multiple URLs follow + currentName = "" + } + } + } + return channels + } + + private fun extractAttribute(source: String, attributeName: String): String? { + val pattern = """$attributeName="([^"]+)"""".toRegex() + return pattern.find(source)?.groupValues?.get(1) + } +} diff --git a/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt b/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt new file mode 100644 index 0000000..0438db8 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt @@ -0,0 +1,86 @@ +package net.tinsae.tv.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.tinsae.tv.data.ChannelDao +import net.tinsae.tv.data.mapper.toChannel +import net.tinsae.tv.data.mapper.toEntity +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.CategoryType +import net.tinsae.tv.model.Channel +import net.tinsae.tv.network.M3uService +import net.tinsae.tv.parser.M3uParser +import org.json.JSONArray + +class ChannelRepository(private val channelDao: ChannelDao) { + + fun getFavorites(): Flow> = channelDao.getFavorites().map { entities -> + entities.map { it.toChannel(true) } + } + + fun getLastPlayed(): Flow> = channelDao.getLastPlayed().map { entities -> + entities.map { it.toChannel() } + } + + suspend fun saveChannel(channel: Channel, isFavorite: Boolean? = null, lastPlayed: Boolean = false) { + val existing = channelDao.getChannelByUrl(channel.url) + val finalIsFavorite = isFavorite ?: existing?.isFavorite ?: false + val finalLastPlayed = if (lastPlayed) System.currentTimeMillis() else existing?.lastPlayedTimestamp ?: 0L + + val entity = channel.toEntity().copy( + isFavorite = finalIsFavorite, + lastPlayedTimestamp = finalLastPlayed + ) + channelDao.insertChannel(entity) + } + + suspend fun fetchSubCategories(type: CategoryType): List { + val apiUrl = when (type) { + CategoryType.COUNTRY -> "https://iptv-org.github.io/api/countries.json" + CategoryType.LANGUAGE -> "https://iptv-org.github.io/api/languages.json" + CategoryType.GENRE -> "https://iptv-org.github.io/api/categories.json" + else -> return emptyList() + } + + val jsonResponse = M3uService.getInstance().fetchM3u(apiUrl) + val jsonArray = JSONArray(jsonResponse) + val subCategories = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val name = obj.getString("name") + + val m3uUrl = when (type) { + CategoryType.COUNTRY -> { + val code = obj.getString("code").lowercase() + "https://iptv-org.github.io/iptv/countries/$code.m3u" + } + CategoryType.LANGUAGE -> { + val code = obj.getString("code").lowercase() + "https://iptv-org.github.io/iptv/languages/$code.m3u" + } + CategoryType.GENRE -> { + val id = obj.getString("id") + "https://iptv-org.github.io/iptv/categories/$id.m3u" + } + else -> "" + } + + subCategories.add(Category(name, m3uUrl, CategoryType.CHANNEL_LIST)) + } + + return subCategories.sortedBy { it.name } + } + + suspend fun fetchChannels(category: Category): List { + val m3uContent = M3uService.getInstance().fetchM3u(category.url) + val channels = M3uParser.parse(m3uContent) + + // Batch lookup of all favorite URLs to avoid N+1 queries + val favoriteUrls = channelDao.getFavoriteUrls().toSet() + + return channels.map { channel -> + channel.copy(isFavorite = favoriteUrls.contains(channel.url)) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt new file mode 100644 index 0000000..b917488 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt @@ -0,0 +1,42 @@ +package net.tinsae.tv.ui.components.category + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.* +import net.tinsae.tv.model.Category + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun CategoryCard( + category: Category, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier + .padding(8.dp) + .aspectRatio(30f / 10f) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = category.name, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt new file mode 100644 index 0000000..4726128 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt @@ -0,0 +1,74 @@ +package net.tinsae.tv.ui.components.category + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Category +import net.tinsae.tv.ui.components.common.SearchBar + +@Composable +fun CategoryGrid( + categories: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onCategoryClick: (Category) -> Unit, + modifier: Modifier = Modifier, + initialSelectedCategory: Category? = null +) { + val gridState = rememberLazyGridState() + + // Filter categories based on search query + val filteredCategories = remember(categories, searchQuery) { + if (searchQuery.isBlank()) { + categories + } else { + categories.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + val focusRequesters = remember(filteredCategories) { + List(filteredCategories.size) { FocusRequester() } + } + + LaunchedEffect(filteredCategories, initialSelectedCategory) { + val index = filteredCategories.indexOfFirst { it.url == initialSelectedCategory?.url } + if (index != -1) { + gridState.scrollToItem(index) + focusRequesters[index].requestFocus() + } + } + + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = "Search categories..." + ) + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(4), + contentPadding = PaddingValues(32.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(filteredCategories) { index, category -> + CategoryCard( + category = category, + onClick = { onCategoryClick(category) }, + modifier = Modifier.focusRequester(focusRequesters[index]) + ) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt new file mode 100644 index 0000000..d7fbad5 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt @@ -0,0 +1,85 @@ +package net.tinsae.tv.ui.components.channel + +import android.view.KeyEvent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.tv.material3.CompactCard +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import net.tinsae.tv.model.Channel +import net.tinsae.tv.util.Misc.makeName + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ChannelCard( + channel: Channel, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + CompactCard( + onClick = onClick, + onLongClick = onLongClick, + image = { + Box { + AsyncImage( + model = channel.logoUrl, + contentDescription = channel.name, + modifier = Modifier.aspectRatio(16f / 9f) + ) + if (channel.isFavorite) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = "Favorite", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + tint = Color.Red + ) + } + } + }, + title = { + Text( + text = channel.name.makeName(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + modifier = Modifier.padding(5.dp) + ) + }, + modifier = modifier + .padding(8.dp) + .onKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.key.nativeKeyCode) { + KeyEvent.KEYCODE_PROG_RED, + KeyEvent.KEYCODE_PROG_GREEN, + KeyEvent.KEYCODE_PROG_YELLOW, + KeyEvent.KEYCODE_PROG_BLUE -> { + onLongClick?.invoke() + true + } + else -> false + } + } else { + false + } + } + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt new file mode 100644 index 0000000..aedf3d2 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt @@ -0,0 +1,79 @@ +package net.tinsae.tv.ui.components.channel + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.common.SearchBar + +@Composable +fun ChannelGrid( + channels: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onChannelClick: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier, + initialSelectedChannel: Channel? = null +) { + val gridState = rememberLazyGridState() + + // Filter channels based on search query + val filteredChannels = remember(channels, searchQuery) { + if (searchQuery.isBlank()) { + channels + } else { + channels.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + val focusRequesters = remember(filteredChannels) { + List(filteredChannels.size) { FocusRequester() } + } + + LaunchedEffect(filteredChannels, initialSelectedChannel) { + val index = filteredChannels.indexOfFirst { it.url == initialSelectedChannel?.url } + if (index != -1) { + gridState.scrollToItem(index) + focusRequesters[index].requestFocus() + } else if (filteredChannels.isNotEmpty()) { + // Only request focus if we aren't typing in the search bar + // Actually, we usually want to keep focus on the search bar while typing + } + } + + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = "Search channels..." + ) + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(5), + contentPadding = PaddingValues(32.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(filteredChannels) { index, channel -> + ChannelCard( + channel = channel, + onClick = { onChannelClick(channel) }, + onLongClick = { onToggleFavorite(channel) }, + modifier = Modifier.focusRequester(focusRequesters[index]) + ) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt new file mode 100644 index 0000000..b0457b1 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt @@ -0,0 +1,99 @@ +package net.tinsae.tv.ui.components.channel + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import net.tinsae.tv.model.Channel + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ChannelRow( + title: String, + channels: List, + onChannelClick: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, + initialSelectedChannel: Channel? = null +) { + val listState = rememberLazyListState() + val focusRequesters = remember(channels) { + List(channels.size) { FocusRequester() } + } + + LaunchedEffect(channels, initialSelectedChannel) { + val index = channels.indexOfFirst { it.url == initialSelectedChannel?.url } + if (index != -1) { + listState.scrollToItem(index) + focusRequesters[index].requestFocus() + } else if (channels.isNotEmpty() && firstItemFocusRequester != null) { + // This is for the video player overlay case + firstItemFocusRequester.requestFocus() + } + } + + Column(modifier = modifier.padding(vertical = 16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 32.dp, bottom = 8.dp) + ) + + if (channels.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .padding(horizontal = 32.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "No channels here yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } else { + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + itemsIndexed(channels, key = { _, channel -> channel.url }) { index, channel -> + val itemFocusRequester = if (index == 0 && firstItemFocusRequester != null) { + firstItemFocusRequester + } else { + focusRequesters[index] + } + + ChannelCard( + channel = channel, + onClick = { onChannelClick(channel) }, + onLongClick = { onToggleFavorite(channel) }, + modifier = Modifier + .width(200.dp) + .focusRequester(itemFocusRequester) + ) + } + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt new file mode 100644 index 0000000..acbf204 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt @@ -0,0 +1,38 @@ +package net.tinsae.tv.ui.components.common + +import android.graphics.Color +import android.widget.TextClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import net.tinsae.tv.ui.theme.black + +@Composable +fun DigitalClock() { + val shape = RoundedTrapezoidShape(15.dp, 7.dp) + + Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 0.dp), contentAlignment = Alignment.TopCenter){ + AndroidView( + factory = { context -> + TextClock(context).apply { + format12Hour = "hh:mm a" + textSize= 14f + setTextColor(Color.WHITE) + setPadding(5,0,5,0) + } + }, + modifier = Modifier + .padding(0.dp) + .background( + color = black, + shape = shape + ) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt new file mode 100644 index 0000000..30ef7fb --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt @@ -0,0 +1,47 @@ +package net.tinsae.tv.ui.components.common + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme + +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "Search..." +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp), + placeholder = { Text(text = placeholder, color = Color.Gray) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search Icon", + tint = MaterialTheme.colorScheme.onSurface + ) + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Gray, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White + ) + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt new file mode 100644 index 0000000..97c1f49 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt @@ -0,0 +1,58 @@ +package net.tinsae.tv.ui.components.common + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +class TrapezoidShape(private val angle: Float) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val path = Path().apply { + moveTo(angle, 0f) + lineTo(size.width - angle, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + return Outline.Generic(path) + } +} + +class RoundedTrapezoidShape( + private val topOffset: Dp, + private val cornerRadius: Dp +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val offset = with(density) { topOffset.toPx() } + val radius = with(density) { cornerRadius.toPx() } + + val path = Path().apply { + // Start at top-left (no radius) + moveTo(0f - offset, 0f) + // Line to top-right (no radius) + lineTo(size.width + offset, 0f) + + // Line towards bottom-right corner, then curve + lineTo(size.width + radius * 0.5f, size.height - radius * 0.5f) + quadraticTo(size.width, size.height, size.width - radius, size.height ) + + // Line towards bottom-left corner, then curve + lineTo( radius, size.height) + quadraticTo(0f, size.height, -radius * 0.5f, size.height - radius * 0.5f) + + close() + } + return Outline.Generic(path) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt new file mode 100644 index 0000000..bf50546 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt @@ -0,0 +1,49 @@ +package net.tinsae.tv.ui.components.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.channel.ChannelRow +import net.tinsae.tv.ui.theme.translucent + +@Composable +fun ChannelOverlay( + visible: Boolean, + channels: List, + onChannelChange: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + firstItemFocusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(translucent) + .padding(vertical = 6.dp) + ) { + ChannelRow( + title = "Channels", + channels = channels, + onChannelClick = onChannelChange, + onToggleFavorite = onToggleFavorite, + firstItemFocusRequester = firstItemFocusRequester + ) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt new file mode 100644 index 0000000..09b9eea --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt @@ -0,0 +1,33 @@ +package net.tinsae.tv.ui.components.player + + +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.type +import android.view.KeyEvent as NativeKeyEvent + +fun eventHandler( keyEvent: KeyEvent, + onToggleOverlay: (showOverlay: Boolean) -> Unit, + playerFocusRequester: FocusRequester +): Boolean +{ + + if(keyEvent.type == KeyEventType.KeyDown){ + when(keyEvent.nativeKeyEvent.keyCode){ + // up show overlay + NativeKeyEvent.KEYCODE_DPAD_UP -> { + onToggleOverlay(true) + return true + } + + // down hide overlay + NativeKeyEvent.KEYCODE_DPAD_DOWN -> { + onToggleOverlay(false) + playerFocusRequester.requestFocus() + return true + } + } + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt new file mode 100644 index 0000000..573de1e --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt @@ -0,0 +1,30 @@ +package net.tinsae.tv.ui.components.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +@Composable +fun PlaybackErrorMessage(message: String) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + color = Color.White, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(32.dp) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt new file mode 100644 index 0000000..87334f5 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt @@ -0,0 +1,62 @@ +package net.tinsae.tv.ui.components.player + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import net.tinsae.tv.ui.theme.cyan + + +@Composable +fun PlayerContainer(isLoading: Boolean, exoPlayer: ExoPlayer, playerFocusRequester: FocusRequester){ + AndroidView( + factory = { ctx -> + playerView(ctx, exoPlayer) + }, + modifier = Modifier + .fillMaxSize() + .focusRequester(playerFocusRequester) + .focusable() + ) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = cyan + ) + } + } +} + + +@OptIn(UnstableApi::class) +fun playerView(ctx: Context, exoPlayer: ExoPlayer): View { + return PlayerView(ctx).apply { + player = exoPlayer + useController = false + // Keep the screen awake while the video player is visible + keepScreenOn = true + + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt new file mode 100644 index 0000000..e8acb82 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt @@ -0,0 +1,135 @@ +package net.tinsae.tv.ui.components.player + +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.FocusRequester +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import kotlinx.coroutines.delay +import net.tinsae.tv.model.Channel + +@OptIn(UnstableApi::class) +@Composable +fun VideoPlayer( + url: String, + channels: List, + onChannelChange: (Channel) -> Unit, + onBack: () -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var showChannelList by remember { mutableStateOf(false) } + val playerFocusRequester = remember { FocusRequester() } + val firstItemFocusRequester = remember { FocusRequester() } + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = true + + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + isLoading = playbackState == Player.STATE_BUFFERING || + playbackState == Player.STATE_IDLE + if (playbackState == Player.STATE_READY) { + errorMessage = null + } + } + + override fun onPlayerError(error: PlaybackException) { + isLoading = false + errorMessage = "Playback Error: ${error.localizedMessage}" + } + }) + } + } + + // MediaSession + DisposableEffect(exoPlayer) { + val mediaSession = MediaSession.Builder(context, exoPlayer).build() + onDispose { + mediaSession.release() + exoPlayer.release() + } + } + + LaunchedEffect(Unit) { + playerFocusRequester.requestFocus() + } + + // Update media item if URL changes + LaunchedEffect(url) { + errorMessage = null + isLoading = true + exoPlayer.setMediaItem(MediaItem.fromUri(url)) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + showChannelList = false + playerFocusRequester.requestFocus() + } + + // Focus to list when it appears + LaunchedEffect(showChannelList) { + if (showChannelList) { + delay(200) + firstItemFocusRequester.requestFocus() + } + } + + BackHandler(onBack = { + if (showChannelList) { + showChannelList = false + playerFocusRequester.requestFocus() + } else { + onBack() + } + }) + + Box( + modifier = modifier + .fillMaxSize() + .onKeyEvent { event -> + eventHandler( + keyEvent = event, + onToggleOverlay = { showChannelList = it }, + playerFocusRequester = playerFocusRequester + ) + } + ) { + PlayerContainer(isLoading, exoPlayer, playerFocusRequester) + + errorMessage?.let { msg -> + PlaybackErrorMessage(message = msg) + } + + ChannelOverlay( + visible = showChannelList, + channels = channels, + onChannelChange = onChannelChange, + onToggleFavorite = onToggleFavorite, + firstItemFocusRequester = firstItemFocusRequester, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt b/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..5665233 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt @@ -0,0 +1,94 @@ +package net.tinsae.tv.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.category.CategoryCard +import net.tinsae.tv.ui.components.channel.ChannelRow + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun HomeScreen( + favorites: List, + lastPlayed: List, + filterCategories: List, + onChannelClick: (Channel, List) -> Unit, + onFilterClick: (Category) -> Unit, + onToggleFavorite: (Channel) -> Unit, + initialSelectedChannel: Channel? = null, + initialSelectedCategory: Category? = null +) { + val filterFocusRequesters = remember { List(filterCategories.size) { FocusRequester() } } + + LaunchedEffect(Unit) { + if (initialSelectedCategory != null) { + val index = filterCategories.indexOfFirst { it.name == initialSelectedCategory.name } + if (index != -1) { + filterFocusRequesters[index].requestFocus() + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp) + ) { + item { + ChannelRow( + title = "Favorites", + channels = favorites, + onChannelClick = { onChannelClick(it, favorites) }, + onToggleFavorite = onToggleFavorite, + initialSelectedChannel = initialSelectedChannel + ) + } + item { + ChannelRow( + title = "Recently Played", + channels = lastPlayed, + onChannelClick = { onChannelClick(it, lastPlayed) }, + onToggleFavorite = onToggleFavorite, + initialSelectedChannel = initialSelectedChannel + ) + } + item { + Column(modifier = Modifier.padding(vertical = 16.dp)) { + Text( + text = "Filter", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 32.dp, bottom = 8.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 34.dp) + ) { + itemsIndexed(filterCategories) { index, category -> + CategoryCard( + category = category, + onClick = { onFilterClick(category) }, + modifier = Modifier + .width(280.dp) + .focusRequester(filterFocusRequesters[index]) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt b/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt new file mode 100644 index 0000000..74e3d70 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt @@ -0,0 +1,152 @@ +package net.tinsae.tv.ui.screens + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.category.CategoryGrid +import net.tinsae.tv.ui.components.channel.ChannelGrid +import net.tinsae.tv.ui.components.common.DigitalClock +import net.tinsae.tv.ui.components.player.VideoPlayer +import net.tinsae.tv.ui.viewmodel.ChannelUiState +import net.tinsae.tv.ui.viewmodel.ChannelViewModel + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun MainScreen(viewModel: ChannelViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsState() + val favorites by viewModel.favorites.collectAsState() + val lastPlayed by viewModel.lastPlayed.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + + var playingChannel by remember { mutableStateOf(null) } + var lastSelectedChannel by remember { mutableStateOf(null) } + var currentChannelList by remember { mutableStateOf>(emptyList()) } + var lastSelectedCategory by remember { mutableStateOf(null) } + + // When video is playing, background must be transparent + // so the SurfaceView hardware layer behind the window can be seen. + Box( + modifier = Modifier + .fillMaxSize() + .background(if (playingChannel != null) Color.Transparent else MaterialTheme.colorScheme.background) + ) { + if (playingChannel != null) { + VideoPlayer( + url = playingChannel!!.url, + channels = currentChannelList, + onChannelChange = { + playingChannel = it + lastSelectedChannel = it + }, + onBack = { playingChannel = null }, + onToggleFavorite = { viewModel.toggleFavorite(it) } + ) + LaunchedEffect(playingChannel) { + viewModel.onChannelPlayed(playingChannel!!) + } + } else { + BackHandler(enabled = uiState !is ChannelUiState.Home) { + viewModel.goBack() + } + + Surface(modifier = Modifier.fillMaxSize()) { + when (val state = uiState) { + is ChannelUiState.Home -> { + HomeScreen( + favorites = favorites, + lastPlayed = lastPlayed, + filterCategories = viewModel.filterCategories, + onChannelClick = { channel, list -> + currentChannelList = list + lastSelectedChannel = channel + playingChannel = channel + Log.d("PLAYING", "url ${channel.url}") + }, + onFilterClick = { category -> + lastSelectedCategory = category + viewModel.fetchSubCategories(category) + }, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + initialSelectedChannel = lastSelectedChannel, + initialSelectedCategory = lastSelectedCategory + ) + } + is ChannelUiState.Categories -> { + Column { + Text( + text = state.title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(32.dp) + ) + CategoryGrid( + categories = state.categories, + searchQuery = searchQuery, + onSearchQueryChange = { viewModel.onSearchQueryChange(it) }, + onCategoryClick = { category -> + lastSelectedCategory = category + viewModel.fetchChannels(category) + }, + initialSelectedCategory = lastSelectedCategory + ) + } + } + is ChannelUiState.Success -> { + Column { + Text( + text = state.title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(32.dp) + ) + ChannelGrid( + channels = state.channels, + searchQuery = searchQuery, + onSearchQueryChange = { viewModel.onSearchQueryChange(it) }, + onChannelClick = { + currentChannelList = state.channels + lastSelectedChannel = it + playingChannel = it + }, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + initialSelectedChannel = lastSelectedChannel + ) + } + } + is ChannelUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is ChannelUiState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "Error: ${state.message}") + } + } + } + } + } + DigitalClock() + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt new file mode 100644 index 0000000..9a57514 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt @@ -0,0 +1,19 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +val cyan = Color(0xFF03A9F4) +val gray = Color(0xFF424242) +val black = Color(0xFF000000) + +val clear = Color(0x00000000) + +val translucent = Color(0x60000000) \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt new file mode 100644 index 0000000..12f8939 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt @@ -0,0 +1,23 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.runtime.Composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun TVTheme( + content: @Composable () -> Unit, +) { + val colorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 + ) + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt new file mode 100644 index 0000000..4990ebb --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt @@ -0,0 +1,36 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Typography + +// Set of Material typography styles to start with +@OptIn(ExperimentalTvMaterial3Api::class) +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt b/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt new file mode 100644 index 0000000..857e708 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt @@ -0,0 +1,118 @@ +package net.tinsae.tv.ui.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import net.tinsae.tv.data.AppDatabase +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.CategoryType +import net.tinsae.tv.model.Channel +import net.tinsae.tv.repo.ChannelRepository + +sealed class ChannelUiState { + data object Loading : ChannelUiState() + data object Home : ChannelUiState() + data class Categories(val categories: List, val title: String) : ChannelUiState() + data class Success(val channels: List, val title: String) : ChannelUiState() + data class Error(val message: String) : ChannelUiState() +} + +class ChannelViewModel(application: Application) : AndroidViewModel(application) { + private val repository = ChannelRepository(AppDatabase.getDatabase(application).channelDao()) + + private val _uiState = MutableStateFlow(ChannelUiState.Home) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val navigationStack = mutableListOf() + + val favorites: StateFlow> = repository.getFavorites() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val lastPlayed: StateFlow> = repository.getLastPlayed() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val filterCategories = listOf( + Category("By Country", "", CategoryType.COUNTRY), + Category("By Language", "", CategoryType.LANGUAGE), + Category("By Category", "", CategoryType.GENRE) + ) + + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun showHome() { + navigationStack.clear() + _searchQuery.value = "" + _uiState.value = ChannelUiState.Home + } + + fun goBack(): Boolean { + _searchQuery.value = "" + return if (navigationStack.isNotEmpty()) { + _uiState.value = navigationStack.removeAt(navigationStack.size - 1) + true + } else { + false + } + } + + fun fetchSubCategories(category: Category) { + viewModelScope.launch { + val previousState = _uiState.value + _uiState.value = ChannelUiState.Loading + _searchQuery.value = "" // Clear search when navigating + try { + val subCategories = repository.fetchSubCategories(category.type) + navigationStack.add(previousState) + _uiState.value = ChannelUiState.Categories(subCategories, category.name) + } catch (e: Exception) { + _uiState.value = ChannelUiState.Error(e.message ?: "Unknown error occurred") + } + } + } + + fun fetchChannels(category: Category) { + viewModelScope.launch { + val previousState = _uiState.value + _uiState.value = ChannelUiState.Loading + _searchQuery.value = "" // Clear search when navigating + try { + val channels = repository.fetchChannels(category) + if (channels.isEmpty()) { + throw Exception("No channels found for ${category.name}") + } + navigationStack.add(previousState) + _uiState.value = ChannelUiState.Success(channels, category.name) + } catch (e: Exception) { + _uiState.value = ChannelUiState.Error(e.message ?: "Unknown error occurred") + } + } + } + + fun onChannelPlayed(channel: Channel) { + viewModelScope.launch { + repository.saveChannel(channel, lastPlayed = true) + } + } + + fun toggleFavorite(channel: Channel) { + viewModelScope.launch { + val isFavorite = !channel.isFavorite + repository.saveChannel(channel, isFavorite = isFavorite) + + val currentState = _uiState.value + if (currentState is ChannelUiState.Success) { + val updatedList = currentState.channels.map { + if (it.url == channel.url) it.copy(isFavorite = isFavorite) else it + } + _uiState.value = currentState.copy(channels = updatedList) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/util/Misc.kt b/app/src/main/java/net/tinsae/tv/util/Misc.kt new file mode 100644 index 0000000..f8c6d65 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/util/Misc.kt @@ -0,0 +1,20 @@ +package net.tinsae.tv.util + +object Misc { + + fun String.makeName(): String { + // there are some strings that start wit a . + val name = if (this.startsWith(".", ignoreCase = true)) { + this.replace(".", "") + } else { + this + } + + + if (name.contains("(")) { + return name.substringBefore("(").trim() + } + + return name.trim() + } +} diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!To6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d4b77f9f036a47549d47db79c16788749dca10 GIT binary patch literal 2884 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 + TV + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4497fe0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +