From 9c11c9c9e0a1ff88fc1730e8f967564ea69713a6 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 14:42:15 +0900 Subject: [PATCH 01/11] [audioplayers] Update integration tests based on upstream v6.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wholesale-replace the Tizen integration test with the upstream audioplayers v6.6.0 main test (example/integration_test/lib_test.dart) and its helper files, keeping only test cases that actually run on Tizen. Mode: wholesale replace (Path A). The previous Tizen-specific 'asset audio' test group was superseded by the upstream-derived suite. Only the upstream main test file was ported; the sub files (platform_test.dart, app_test.dart) are not yet consolidated. Tizen reports as TargetPlatform.linux at runtime, so PlatformFeatures resolves to linuxPlatformFeatures (bytes/lowLatency/respectSilence/dataUri = false). Ported helper files: - source_test_data.dart, test_utils.dart, platform_features.dart - lib/lib_source_test_data.dart, lib/lib_test_utils.dart Added assets (copies of coins.wav, matching upstream): coins_no_extension, coins_non_ascii_и.wav. Passing test cases (validated on TV/rpi4 device): - test asset source with special char - test device file source with special char - test url source with no extension - data URI source - AP events: #positionEvent with Timer/FramePositionUpdater (asset source) - play multiple sources: simultaneously / consecutively Skipped (platform-gated, not supported on Tizen/linux): - bytes array source, Audio Context (respectSilence / LOW_LATENCY), Android-only LOW_LATENCY release Removed after on-device validation (fail on Tizen; not rolled back wholesale): - test url source with special char (remote URL playback never completes) - AP events #positionEvent for URL/HLS/live-stream sources (timeout / position never resets) -> data-driven loops restricted to local asset sources - play multiple sources with URL sources (timeout) - Set global AudioContextConfig on unsupported platforms (MissingPluginException: setAudioContext not implemented on the global channel) - Race condition on play and pause (#1687) (remote mp3 + animation teardown) Note: only Tizen-runnable tests are included. Network-based cases were dropped even though the device has connectivity, because remote playback does not emit reliable complete/position events on Tizen. CI policy unchanged (recipe.yaml keeps audioplayers under ["tv-9.0"]). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../example/assets/coins_no_extension | Bin 0 -> 39824 bytes .../assets/coins_non_ascii_\320\270.wav" | Bin 0 -> 39824 bytes .../integration_test/audioplayers_test.dart | 499 ++++++++++++------ .../lib/lib_source_test_data.dart | 141 +++++ .../integration_test/lib/lib_test_utils.dart | 31 ++ .../integration_test/platform_features.dart | 162 ++++++ .../integration_test/source_test_data.dart | 22 + .../example/integration_test/test_utils.dart | 103 ++++ 8 files changed, 802 insertions(+), 156 deletions(-) create mode 100644 packages/audioplayers/example/assets/coins_no_extension create mode 100644 "packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" create mode 100644 packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart create mode 100644 packages/audioplayers/example/integration_test/lib/lib_test_utils.dart create mode 100644 packages/audioplayers/example/integration_test/platform_features.dart create mode 100644 packages/audioplayers/example/integration_test/source_test_data.dart create mode 100644 packages/audioplayers/example/integration_test/test_utils.dart diff --git a/packages/audioplayers/example/assets/coins_no_extension b/packages/audioplayers/example/assets/coins_no_extension new file mode 100644 index 0000000000000000000000000000000000000000..c0dc31c28aad5f1852563d83902838345fab2cd9 GIT binary patch literal 39824 zcmYJ*W0W0T!>!?{gKbt-Y}>YN+qP}nwr$(C?G&~ZbUNvC?>FZeUtNFa9Q~(yud!5( zYF4W@V|9WA%_}ym+O6-943-255+p>GJqZ%bXpt~Mq6C%%T{;fw*o92}0ypzl$$u_i z$$W?N=F7VyPv$&pb4$4w=Q8G+ku!15u{nNbADrErtw*-cS=(iOnWagVZ^;+Gk>0L* zoB7?&clq8Qd0+Cw`43e--uzhq)00mvKfnLn<%{~I|JU%>k>CD)i$?-^yy36(M~cI5 zDOUO={gi&-yA+YaQb-CSAo(Sq812SdM-V~Q|XEHSb8Kq!~^NRbWge~ z-9dXpv>R<_kTc4e9o|&^Ix}sTu?3~7nY02MNv#HE|-u?%B4_RE+dzf z%gN=jArx)@>zWm?%w<#!KU*u^1yclm6EoAP~rN#2Yt@>Y49yd68_ zo$@Yux4Z{?<$dyg*`BoC|L@k8YN4i7L#i%Sld7VMR9UJdRYV1;yi`sqE0w`H&wt$m z@Hq~ zzmyMor9Avs{MS7%Uyv`#m*mU1B43rS$=7j1zA4|5Z_9UZSH36TmowY`>*khnNjZ^2 z$}VM-vLcI=S;{13lrkW_luk-3rID<*|LZ=GAL5bxSbicu#WVT2{6c;yzrt(zjr>-A zhxqvax~ZkqQYtB>BuSzqz$rN-yJUk^vPfph1fyh-RCf-(oC&i9f|3;&(*Euox19VgP>8CwfH> z+~R-TcsX89pd?fhDT$FpiBXa&$&g%0q39I7Vt`RGDT!nMb=`Dbq9&@M0$Kbfeigrn zpYcijD1H#%#D{nw-WTtQckx00UpKvyUdfWK*&uhmuptrQ}xf zAg_{73B~;D-Vtx(mUvUVAzsHd@v3-5yo^iYMe%}o9_Pff;u$fmH`UDV0%0sj5^{s-uQdQ>mrYMjfTDQct;T{ja-Q+%4`BcZxg2 z?bs%66}O0+#ZA~KZV=at>#$ZlWc}BScIzt*l!j=eG*+4@P0>tguC!2EqLtEGX`{4N z+9~nz(e7HT5m$?=#FgR-EEkuFOT{HvEG`lkiVHAboF~o|=ZM+E|8?6d?UfEnN2Qa} z8C{gFN;joDdMG`WUP^EDQTi&4ywT>r?rd=uW{NY!>Ebj@6{m=k#YvbbP7ue7<1kiy z8uPE&SLvtpR|Y5pF-RG#3{i$+m@-@$p^U^RWwbIz8H@A!Xm_kQ2BXDM;z)4>hKs|* zq2dq>76*v~#R2Fq_7nSxeZ(E zqs@Qa-eNEG6nlu>#ct>-b`d*^oy3mlAhs9Vp{qAbNS zWx29KS&3E3YGsYG7VZDnZ7a4BTcefOQfwhMM>Dah*hFlMMq)#;fmk2)#Jb`J{p$bU zU8k(a24$nNN!g4o%2s8YvK>2=oysm{H})udmB%svnsvoGVr{XOSQ9nG>S8sqDyoQ; z#Y$pDR1nLH<;1cW1nnsOaC zl$**ejpca_f8f6csN9x=C=OU#KJVsSx!>|8;_nq<{AC!;EC*?D~C|{Lt3WJ5BD5|0XLTk*iF_!PeqKu`%OVI_j^$`9qI@(Z!bZzWFo zqm0)_o6&BZ@LPz*FX5-~L->w}5Eep05COq2_yjLJ!oOy;`&Wra0yUwUNKK3+YK)px zO@`!Z3RS1-VNff@`u^AT2yVdzO;81X@&#G=CVUmX;Ir^a_$Yk9d*Pk%Rwx-8?HW|0 zYJyp{s8-liyXsJ#5LA)rNlK(rQ>$szwCaD&x569YweU)Ki5J3i;TfI^PlU(9BRmuy z2=|41=%D}CjCRwh>D3I#sAf_#Ba50<&8B8Y4mGEmOU;cu>fxCC|Lfir?g+PWOSmcA z5U%5za8T*f8gqHqD{g>%AL;f*)i&7KftJ+QNjvi`HwU^o(R^NZk zxxyS_wlE7bg&D$hVH&0iQ-sNwBuo@02;(tMSQgXkf89Q6U$r0ls{_=5>L3hOhp0m_ zOdYO{P)A~vI$9m0me>DljupmWv@l8-DU85yVVE#f7=ppVAYmW|2>peALSNy(<`{LX zI!+z0PQXNUk~&$Pf~o2>bvkCKGu2t@Y;=nKui01VBlH$}p{LM8=q_|aSD}m08J&cV z!ufEtF-M)N&cl3lfw~Zj)Wzx&bt#sq%heUQ6aJU)Ahbt2p{>vct%X)XOQ8jt3(bV4 zXd<+YjpkRXtJKw4qpnrgsq3*p-KcKDW_63YRo$kR@%_s;78(f+(Lks#)I(jNj!;{u zg_=SQp*kY*TmRSCuI^BGVwbvG-GjaAK6Sr(00-4W>S6VW`Y&His47$uDx;E6QK*3O zLOG$VPzI%iQbI{#T1+$_Z5&mPsmF0bJ*l3;Y4wbHRy~LF>ILTve~B*VP-isoqj=7fZcj|k5P(P}l)X(^$epSCgRu%rw z{^c!#879Ff7@!w)LJA={k_kzL7$gyT`J#DM)!s0Te;^!==D({y@KgPz#;U&&r~Xm@B3@0PCDaljLF8Zl zyEEbpBjgM^15Q7DPOsAgx6|d+_(>lT8_g%yl4vnVswLBsBZa2Z^qK)i&7_$z$M-L< zGHsBZ-|*G>#rfI!2_KywobU0@`PTWy8HtbPEt*xcX?8d?r^e8uiJGLPL@F({mPSj9 zfBDzW*UneYm(CY>?tJEa>U@I7&PUFNc;LM6>>cwjpH54!Wk5zPla^V_f~;CLEjw~( zIkjA9V~OVPIq%|*^S1LAZaQx`uj88Ys`HBTGA=n^>;C0)Yk9Q1T0Z303TOpUNGq%r z(Tbv&R$MEgebhzs7o8WJ=W)(?)_De}ou`~9al(1rc??ILRV@GVCA5-SDXlchXl1o> zT6t8^Dr%KbS*xN|)%wI7C4a~mUE_a2BtfwIj3TZbFy<1 zCORiL$2%9rM)Q5Oep-JF&<1LQFjyO+4aG2RxHbYKwb#CX`Eky%7~>r69EFk25zgTl z<{auAg2B!~ypbcxkJ3hKV=z`5r;XPpV4^ljn~W*iRBaljYlHunALtx_{?2~RzRo`A z?d;|3i5|}G&Ti=HY#0+wPuFH>GqqWmtT!?VN3$ZJe#q%GuJ{0^4-|@=LX4+H!3LR%)xX)mWpg)z)FXwn5v7-MSXe=4j?@ z>TH6>&PL9LXyB~xtcSYJI?mdNn4|ek*sN{Qwql#MUE6`3+AeLkwg-E)ecFDlVN7jj zEoV*Ca8`F#Lse%LXJu4!R&-WCd1pCiS^Uc%&<NAqWKPCKt%z(wtnc3HcEtJ*c~ zI&Nq;wF9yL^7)+koOzvjoVk(9nbVoWnH|}jS)Eys*_p}NE)vb((r)99c2~QH``QET zAs%UuwI_J0J=1zcGXF21(U}41o#~uuk;a+YnF=YLl9S1VQ*b(+dt;xGf3CgIUgDMZ zT6=@H+B@w%K4>4cPx!3m@<-E7r^9J?+F*5BoMxDuMyCOKr_PxI$$29`lmDW9)xJU2 zl;{wlX_`xO!=rgMpXNtEi>8wysWZlz1c{xAoC%S@8SnUuKaM!ZZxoA(rUP0~3u$3Q zwC~yv{M3GFvG}dUX@BrnJ7fOsh{Z3*Psb0(cSIavM+iYjz~P6_;dOW%19j2#UoBos z;7aI9gv72St{5bBC37W53YX5M$1q(q?}p2vIhZOq6o>5ihOdqkLI%@hbyNm7jnDuxbh;OE5EA%3c3op3cGH{M$=avR~(lemvGT>!Eqkv z9A_P8aN2PSCmkmoizCr|5m!+Za}{@$KuK3AS80@Sm35Uvc~=G3`bac=!g1Vj%yAS) z9ETl;aL{qUu^;;!dmVeQ+i^X%0{M!rO0LSN;;QPZhU%^wu9~Rjs_m+Sx~@_FXnGfR zI(9g=W1C~EV+%GrHaRw8gJZp89U}2{$=7q$cQrsmS0h(rG;uX`HA8b(3s+0DagVW9Sa<(W1{KSt~Rc=u6Ahe>fq{#POi?bF6ip& z=IV|fu0G~ye!gQK<~rs$W@DCPreg-CJEmc(V~S(4ql_+^?ulNm-mX6A>*|O8t^uxr z7~~r48iJv&in__TZ4@WdV*);`IUDI6C zF~c>}H4C#{b6j&V&ov)^;-l#vj_!_bj;`q9=evT7spnWv=B|;acfhh1IS#uC=bOvC(uZv~;v^G)FT>Q%4gtb~HjmM*~NF)N|a3 zMDy!h>#@PL(X|PiU0YmRvCXyJwF5g{yIfBq(R5u$9n^NzLQO{vM|D(lRCQEAWk)4P zMSP0gMSizyk83aXx%Rsb;GpXe4!e%Hj^dc>xa+1rny%m|k8+N(jxs3iDCH=L5{}}I zVkqh;;wa1;dE9jZCtas-+I7Zt7Ux{&T^DfCb;)%ZS6o+JMgEsB|LOnbigo>V#o>?ZuPYu2+zFA$o!FfOG47=9WbXLb zXxeZ0!E5)}-Ec)G1$Nc0*x9zWf3ttJf5D$fGV;mYDWG%f-3A!lCbt$$`I~7v9(;%%oojW}; zxHGz?z;pXEJheZ;WBVifLp-qGx8K8E`yJf2-?HDdhvPH4Ga<7(i#sc_xw9jOJEuDr za=Y`m^CF)+zdM?~X}@8=Zoh`B_ABj6?Q=_5;{&f0!bgE{k&R^6m<# z=&t0hj4JM`sOGNju7R5FXDRmE_u2Pik9{|G*>~D^V7q;reJi%uH)E6im?@gB<*to7 z?z*VwuJ3MuhVDl0#%SVhie~O_NjBLx+BaameVu(R*4S6uS7D`n1(w^F*_UF8y&37| z?iOh2Zsl%`Htx3Wc4+VJfR64??#}oVzl8K+`ywo~FR;(YJo{YCvCp>8!c6-N`*c(f zbar=fcXf9|cXtnWPxNy4Mjv-ycR%!Z4{#53r}`aDPqR<8Pr+pSBuunVu#d+$`&j!J zjJA(Lq3;994{{H755Z9PFbsE(aF4_&_h|PRjCGH5m;FAcnChN}>FycsnV99C?d}nXrhB88y(fCuyW6{= ztGx?4+dJ7iqJzD?JrY0LJqL5$^W5{Xz`YQQ+>6~yu++WGy&NmtE8Wp_JG8a8vA0Gm zdrP#iH@7!KQ+pFMwl}ihNfJ%3!fN*#_gbuTug3=WM)xLcc5lH}_cr%fQzLsrdjr(B z*R$6}9eZumve&fNKy`aHRJE^35lwG*@4!y?F6?&iaqq=G_kJ93A9NqWVfV%qRqa*m zmF<;K(Ov=N?d9xcQN~^xrR*i`CD6@unDi0%Q5cYkny#3%P> zd~ttue}n8++=ISH(^E~Y)5g#b{O9RB|IfO zB|W7)rBTLH7UewUJrz*VQwfzlRZ#EuVcQ|wK^(B{$3EL$+aBz;?ZQsm4s5q=vu(Al z`Cipi4b?q0P}5V(QyX#@$Z*0u(#ZL6@- zw!&8DcOy?@PZKotG(&Sw3$*mK^0Y=9Pg}I}wD%+nt{}bKwhT*cOKgj=$hHs*Z1XYC zHrF->vw0)idpdYJqLZhyrwh7zx}m$L2YPyXd3vLdr>|!==~=d!wi%din}(^jDYnU& zWSfWyw(%HeTb?AE?&s-`0iJ;vX`(HLbLX&Zsz zwqY1*8-l^MLAHS?lwuU=(Vj6F>lx=6j|rZMnB%A$;|wC&ON{hkA!gE-_lj3b_-IOaKy6P}ZvQ#kE8<9YMFH0e^d zk|<#-j$*c=wjwBOD};i!0?2R6XY2O+jOVQ99L{?#;G*Y}=Q6H%uHu^KI&OGwdYT3E z+43TfEjM!6aw3NcbkXG?+Pwq*E|{EO!+zIix2#in+2^Fs45jqtcV9*-A3 zj~@X~!W2nuF-T%dY)gcMwgiZ`{>2|_9DZA4t-q`zi~&y&Ay3#7!FSIO{Pg@ntmil4 zJbyfYF*?aF>rd+se78mrwuTV22CRPgtX_DmObV=jJ@K9d-UQx+-b6_3O@bJ2Qg1RO z_ojf(t4CbC+v>7v(amfQKyhT&`pxr*_jKE@;KLu;}hDZQz@sgcH; z7U{g{y%~_vn+ch{S&-G6&1?Pf(E0%Pt@m)(dIz_yw{X*X1J|wBaMgOnI`MZlZ+34E zZ%*X$=0+ZGUgY!UM*(j^6!I4KZVO(qUdAQsMO?6+x1PgU>lvK3p2A7%2^_aZ;){5T zc#C?Ap}4mMN_tCqOQVdpEXsMyqk^}h_qg?#^(c;5595&aAP!jfW1n>|_E>jYccD{~ zirz}7?5%>T-fF1st$~`}TBz-<v`*=fwv(Vc^iA1psBYRntNNIrMDGYd+#J)V_j`sg_YJ7SZ-a0rPd`_Y+Zzf)&w=ep6`(uE2AO?8{V~BSs;^HS-Ct4?1$77szEXG(zW0Z9yMp%bi zhheC7i1lf3n0L5$1V(yCVYGJ)#(KwLymx|kA|`n!d)NIQVjYY@)`1vc?T>!ezUX7^ zjb7HC=wa<{E%RfFcZzqacN(U9XJDpx7G`_rV6Jx_=6e@-tNrM1?S`(_F6eCSgpSq@ zXm4$2ZHqS6)@Ws2^LwFpp?8sYF_w6jVwrb2R(Mx>S7Ehx4c2-i!B*Cm))r`PZHA`S zCTMJJgof4zsBf)@y1bF=yz8;SyAhkbo3X{a72CYqvBSF)yS%%-b**)*wNcAj6E&>W zQO#P_S_PG@l~B=I0o9Z2_U^%6?>_AJ9>78GA@5-v@gBu7?{S>)`V8f*<*a2<##$Ps ztR+#xS{%i!MN!0B7>kpi@SgOZ!fEdrob{f=dG7^W^j^Yc?-gA2E>B*_TF_bm`K|eo z*P6$g8@a4Gk;9rD*{oTuFAZ0{*Syzp!+X0$60Ve*+_@i8d*Y%Fp) z;Pid6e6oDBe879lJG`~L!E4JaytKT)bIUX2P2%K$7Ked-k}oAv`BEc|FD=sf(j$W} zBQp8c8=hL8SRUh%EBoy5*YXDy~>AZ^w8 zz8a|MtL2M}-)q@}-IiV0Y1x78mTlN-*@DfMP1tDJU>O>!?W^spBwHzHaF5>w%uWUg+)X zgTB6g=G6aJygD}uC0R1if(AUxj_9PR06MPeWlQ7vg1yeE2 zHyty4Gcn6I8*_a14ZSSAEIlnf(B0AvUD3tT8J#R0(ZSLlxsuQI&GpUm&Bp@YLM-wv z#uDFBEb}eL3g1fPP2Seh*3!n(8m%lX(ZbRk%`8pP#L^gzEDbHC46A&rvBtL+>wN36 z!M72ce4DWaTYcMLP13+pAN4GCQO8mnwNTSi1Jy0nP}Nce+kHE*)3*z|eS5Iiw-5V$ z2XN4L2#0+~cq1!YDxspK0?J#;p{%70N?S^yq@@IkTZ&n(hK~A<;kfSvPWn#awC@bg z`p)6J?*cCRJim)sidu@Gu%!?RS_&XP@>%jCk0m#9S#nxl|G4D4?7M=ixaPZ#8@`*k z<-3hLzPq^R`}!lNC5I(DvRSesizPEMSu!GnB|Xwv(jtw;{rkS}f$t$6`5xnm?Q_+yU4Z*weu;ivfrzMCUa$$t2L;+HQLzkPA|Y9R?M&?TlKWFY=hwsF zH^Stn?zb>``JVWj`76GdKjV}6BR-hl;~n0b-{7_R6|5|X+2QaHQh}8Ij4K8Cj6kpAFgl zIgrzz3%UKtis3NGW4`64cu&zr43^Z4`n^C7>#01EmGp|HOQ ziu#M8I7;|a{XAzrXFh8_gVQ)=K8X|N<2YtMiX-O3IAl&5SIS??U)o;=W&Pz)9u@o* zQORE!Rs2;^IDF82(0stWAN$OEvB$g{yUaVW1KZ8pu+9sj(IjNBFYzskz$E<^JXV75ZiLp`p0}>Z6{yF6x+Tqn5cQYM3*|ZSil#Hf;Crz)t@z?Dp@$UjIJq$K-Hzb2U^& z6?0`&GFLyEz-O znzJA?GMO`CcCtJEJN~=4=f95!{)c$ve~c%1>VJmk{ulmv$UdYk-!{p ziZ}hmA5$EDn_}?`N5Wx$*dOtK#}EHc{6eh%H{$$%@Yf%YvT;95KTO|E5rh#k1radu zQ*UBYVDd({Bus9T%QX6D!ayP<4kSSgk_M6?c_0OJ&<6~d@{@zMT)N>t?3P3n_l51 zUYMTandvE>;IZiu9-6+#rw*hEq(!uL*P%=;orBNnO7UcrvQ2`YLl>#l39W@=nVbdWTG#$Wx>@)4f z9@B2@LY%I0pbDx6s-Ze+1ZtvIpf>8DZlE6O2ev2PVcKrmhOMS8*o;l4jo5(orgd0r zT7w3GhJi+Cj3$AmXclOW7HAo0h1O^jh>KrkT4h>^6{h7_W?G6RSZrE^g{B3Vk0gmL2I-!%}=~Iumnr7EU+9a0xPi! zs{?DW7V84*0~d8IOwG~E)D%t7*whFO(ZEz6^-OhPD%jF2&i0Q|-wiT?!tB0i8Hm>`%imAStD06 zjH;2#;L#6=7{3|6;*0S!+(~soeb4}7bU2DmU4oV%8z(`w*g3ex0pB1OL4rcim-M6Y zgYiAy;jQrvUgMSVC0^jU@fq6cq8~U(8BB%LNE1wpbVwh}fQ-l#%#1;a9~&PVAK{_# z0q*0T@hA}tF^J3ZpS5 zI2PkDJ~#oVk`6ZxGY-WN3^oqJKnyVUM?ds6_JK(^DL5HZFf}+0(=j7B6SFWoI0tjl zJaJEBPh$^sH+Dl;bTM{DCv-G+Kzp<^&JQlY!r&q-#**MtEW`5P3arGc;A-B;HpVu_ z*2Y$7i5AA@XojZ7CTNUC#)b$*)&|#MJvIb4ViPt8w_q!_1-D~IaC=;RV?ESG9b;|O zLQP{0R7W*bHC91`U%P_4u_w3}`>;QF00(g>co;`;G}!W2C1XWYFqTI-lr@$?X_P`q zV+j<;_PFE06Ty=>h10<^IE!<^^SFSE!Ar1yFKR4eEQ~@ZXe@yI$cMbfJjjh)#+=3< z@mGUagV%69cmp?aD|j1sa5s1l_wgW@-I(2&4Oxv@kQtef(U<}0k__jU0h8+Kg;oM3*;#1DKoL=ZN_24jQ25f}V} zzlcYIP(ma^;!qOAgyQ0(KlcFx3rz+eywL+u(WA45=rs$?z*TNT^wS}-q4M`+q2!?y z&_NGFh~X&2)Fot&Zk$B7*g1@KDekM`3qIqM;UhlaJ>D7K;tgIKUSaAlt`&y3rXCVQ zdM$QH_u z9LR}Wq1?!Wj^A$^ZW(Uk2Cf^f;VQ1+vf&ag;sSoe=MNP?K@-k zoHd*^oWW_sDV)R!95)=pQ5?Zx!y!Yq#AQNdQ7%*-6;Lr$36)U=RYTQK9g|ZWFzh$% z!(QycZo@9@#16xDY{OPVy`(imwL-N~2X#aBP#+D@Fw_W*(InI~)FA05!zRN^a=GvKlJB~oMD(@m~NPcshDDzj7gY?35M|) zhp~pm-v@;Tg$83tXefqZcxVJhViZP)#^6odXu~Ls#0bN148u?iF$~5a48#D#>0jeR z6EG2zFgY{@Q!x$GLo+ZFmw)v$^hF;-Z}dV>^e}WsH*`f8Lub5+n-iLgd6*wsfQ48T zT8t%Fie;hYp#$GL8af!-qaE6!jiEJKp{1b(nxh$h#IFpk3a!Q(ti`&}dThW(Yzl40 z7Bn_AHZ(#*Lj%-DJ=8VSL2cARO+yWXKJoU@_RtRO#IDe8?7`m9KJ3Q<91I-_6-iOe zP}NWcl~D;54HZxxZBzMB@D$; z3`Gq^P#A?!&`<#Rk`ukkgO@*^v!d z4Ox&GnUK+t!Ei5h9}hwg@d%IcB=i)|@Ek8fFA*1?&X5*qkQ%8BDZvM%41C(dAQ+-o z^Bk}nK7D@^dW&~>j}M`b_=L~+68eg7_z`C{SirIr6AlImL-f%@XGnqMNQR^a|1UMf zg}jg}6g^7I(Wej_+aX`b52g{(AtDqCg+o7oB{3vMB11y3lVRXXQu@F8KZwI`#Oi_oZv2Q( z5>671LDFzCBu5J9qQx+SQFP+MB9}SLr$O`zWc@dM#TR_WC;dl!zKIJVI0Ci z9Ke44KJ3LF?AGtXPJN*t)xy=oHBb|^P#bl^bx{xX(IDI~Z2hxczYSZlMZX!Fun`-u zUcU}&u|}UYwn?~2xG9>UdAJ2yq7_<)+n_Dlh4aR)(yzn{{ckvEgwTj|rHFN#V(u zf~lAmUYdN6exQB;`lBEEqL02edZ8zJV1LZa@XYWm%*Gtd4bQ`TEWko63NOaNm@fJ* z`p)Qtj`|L0k9KH_Hu~0RrJtC5S$J7^IaY*MVii_n4c3O&VLdkBaiZq>=K5y(rf7o3 zXoQAnps$a5sEf_vE!c`}*p40Ho!Eul*n_>{eY}yi^fgff)lm&q^;J+Al~55C^yT%F zejE%R3?IVb@DUuvF&xK<@JXD)>2RJuW%XrH8m06lQ3AzL3`J2yUl@h-tz*xH&*K6v z;u0>0uiz@K;W}=FJH-~z=hx>$UgSY;Sb31$ocX2O#9}n;lkMKDB z1W&R4M<#tHeMV$JdZg2*MH-|=Dx`#@{~7-x{4)Fsuki+N@hM@HxyS@93pG zy+n=hqcVo*Hw+_XVF}jBc4k%+Ui|(WAZ5OCZs!g%K{HN1PD>d@3;_MdIQ==-%TU-r@~jmK1D9^k(29`51}ZsQhi>Tc*3#O8?P zM6O6~AxP*(ifb%$~JBu?ot*id0Sfp5_I7*--N}+V5 z49cP$%ER~LgzkjyIF8{cj^MEF5Dwx1_G6#!XME*I?vWmm>M;v+3v}}_4|6d`Hyg7s6EiR!pOf{D z^p5mFU-UzN48Xw1APmM342`TyG+8%UHwhDU6EGg*FcxDl8l!Y0F(NV|G7_UOIx+@h zF%IJ~0TVHaH*%P6D28A#24Nrupuesk`l1gq{hSt=7MYG2n2A}KjX9CIn1}i3_@}3? z2fCvhy6U>1GdiIoI-otS#x9O5jx51aEQ>713arE`ti~GLjcucAt!ssrXo2QvhNftO z#=1smsO$7+Lu4a1VKcU1E4E=fc0_h!SET&Udb)bLx~PNNsD+xSp{tH+sER7Qk$WTi zupb9-5Qid%aRf(k498JXS5a31P!I)>ANi0Md5~L|3)Yw$ksFblxP{xegS(M?xQ_>Th({4yOg3FM zT~=g4W@JJ}T?V8_I;6$oWKSbcBhTCC!MkSr{Wpc~Kkz$#!>{-SKjSA5SQz^Oqx{#@ktNP0 z7zGoG>+v`77T(4?_&eT>&rZCIy^OuUb3DUSJi%i;!o%1D+>a&058@xh(;zLR5YMdC$K48>6bB~c2cQ3fBOZ2XaRICeO82nTTh`>_vu zu_v}0yHGV+Azl%cP#INFHC_!LqdGo84RklQ#kR$^VoPi@Hen++U_I7hE!MxVp{`LaRxMT)RZtm~Vii#V!*RaIQn8XKf#N8Jq9}sGD1?G25PLg$I(|BS24`^&=WziS zaS4}kCBDPY8_R>-$c3E9f$YeJ50MpFVx8UV@$2y$xQSc19lwLSxQF|AfL?CKSO%m= zx>#DI0RiPPVrOIT;U9PxJN+l|r+9|vc!8Jr95s~SO42UcJ4^kea+{ZoK#U0$nE!@NnTt}AFtoRVwkR3UY6S+B~TKjaLGTOatudt1mEH?4&fjUU_bVy zEOE=GmP2_|Kt)tSWmG{`RKqHFcgim8#13r7Hf+TfY{n*ROu6jWOs$z(3!kDk>Yy&_ zp*|X*Ve07Q+LW~^Yp@#M;A^bHO02+gEJHGEn%XqA8JeR7TA~$NqYc`k9Tul7PWcjx zun=Eh0p{a#%)?w{GCHMpMi+ENH*`l2^c1~Pd!_b9x5$i?87b2-O-xOhnlc5GF$ohf z0p+d!sRJ+&gD@CFFchC*7=~j+YI$pP%IK6)7>N-Wj$!x=Looz{Q<_9Zr;fo`jKg?L zz(h>KWK6--)U-zbl>RCG&=-Bs8@MYF09L&W$e2)28fG^;Ool-iY z1KOh<+Mg8nFl(H!wp$tl+6iT85ii=_?#Zro9_f%4 zX%Mp>ranx4gvWS-r+9|vcp+YRlHY}JhphH6#4Wj|#scI#VgacQ2lr?+=2;qlL zG>L@Z$3MzZLR~tk0!8#s@Hi@}9@6TLm9P>vB2a&x0(D$bms_tx71#fk(1Tx9z*Lmz z!YCoPDk@!#*N8HdDf$ZEij+@D� zODw`dd?6M@+b7y5I*5*mj)_j_j4tSkZs?942|t`0og-#PXGdpYCT3tdreP|kpuW>5 z(HH&D9|JHDgT&y(;KUFNO{5PdM8{(s#$pUci&4>0(UBN|;rPfKnNUVyG{#^o#$h}r zU?M7cL!yH*2m>(y{m~D7(MR--_Ks!@rX{8®+XVisnLIf*%mxtNFEPPb^cXjgO* zoui$jozM{-&>rp3mM?N)VqsztzQkgDg(X;uWmt|CXc=u8ZGq-!hNfsD8b=#P8=)Z@ zM6c=JBvxY$)?%GlpID#RfQ{IM%@}UiiPpxasD+xSflp8!AB$?y73TKD4(!A(?8YAK z#XjuE0kOiY7_AtsfbybTv|O|-K0+CkMk#c&k0g#Hj^Y@OixY_xiIX^m(>Q~(iHmyC zXwhg96hx_*372sNS8)yB;kvkyxRJQY7nviPU1W=9i++f# z$b!tsgp49Xbd_^2aW8Qn55&X7!^9&z#uGfnGn5F@L>Y(_Wz0pC7;~uTS%L*sY?KNG zQEl8&n)IUmyjO`=2?ih~7;2FqiabHkY=RiU1hG#b)PuM=h8r9sH?^73v}iaj0!Vtt1?{@DhieAQD7;;YGZN3kON4Eg)5>%O^L~k~$2S zuwX;}RBbNF6sXHBS~67go@xk>5FQdCW(outMu@tN5Q-jQxJKlM$X;DFiO^1{TF*hK zi3-0;5|fxDPB%$Rdy*j@$YZ~Zyufoj!&5xLWAP~RDDn^wB0bG?$#lu|$bgK$eYZY%!m9afPyF_3MY5y z=OgDM=WrHha9W&-oQj;p2^`0P$RQlW0qn;< z>=k<=dm_8BEAlU=O0ue`maLZi7}fC!YM`d5m8_MlA8d!Sf0qLFBvY@BR@rf7!d$xYtZkyTiU6sUZ5QCCa zB2yxhF$ojJgvf-*c#OkXj1f!q&yvH$@Z|912#mxij22^(W0GSrF8RP39vOzuFcd>D zSPY5`iVVa6^pBJ_KNh1^b|cJJtE!F4P8Z-NS8=w zbc$TC<|mZ}_yP;D2w#fD$;HX9umlVBwvo1xHfW7jqGhCIqy?IznP?hm8d;fKnOudh z@r_uWT%BBlwOEJsA_yBq8bs=&9_pfws2!;t`4qKK6Ez~0+^tDv8@6Kyc8XoeUCG_p zgT2XKLA6L#R1uXUl_Ql<5fw!FNcl)Pl#M*_4kZsI593>LBzYux6vuEJC&bC*Q?FE{ zBubz-iix6;qLCsfj6$Mdq)%`zc`kV#7jO}m#O37WV-o_nqH+eUC5BKo^55=S8qvYdchDe4;dZZI+BWWXP!0@~XBay(+iwGg* zk#YL-CC@QI?fDpw@qLLDa5HldkDMA4xkv_m@(zU|u{TsZb{%TM~r03kF-b97-i zhGW74e}<8|DtYrX%I2{xRBHw;d^YXgHW4BqZptQ@(0yHU$HOa5-y4h z_67Sq&f%;$W1q24+tY#~PEiy?ag-1xosv!|lon;2GR|iAn5`Ve5qv8S+lTE#IEVvc zzwL$>*bD6W_#E>@UaPCq4c*a0^mKYUz0ezdL|><`)6Z$I&#-6M z)5SD$JbPnM#z7&^|_6t|pP&TZTgcb&V=J={liJ&T>i&MY$7ne2?nAky3E?Q}>h z(%4U&r{bCO%z2I%;-&M_`5v!?8mJnCD#-lrAXX46IEqjZ`%R(R^i&JR8z|%v9q14Y z1r_H(tF|MJc&dRyJ&sjRpKv&c+7V&1EMeNFZ9o^At=XX!S|fai<-&t60w-`nXs)8e zaL4+NI#8&~C!~V1x5X1F@SIk)XMkot>u3;Fr4dz1$7@i}d8PTorB%?S{Ty$)wY*DE%KBb-Aw!|?9^MxpxF5J_kXEE~)4A!9!F^&qu^x*@)+6ho zcwjxS?&F@gYu&Z(SVi@$ZdUh0k` zb<3f=sNhy`D~d{PCATuFh^lT?H^08m+Gp(*d#pXyZn4YSW$nZcvEABkZL?~+N-caU zYP+@FI;bn^x%J%oA_zBH8?6mkFV

thHi|wZ>YFZ^YNuSg)zuOf+|!yDiXCv~pXy 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 HNLc?5T?xoE literal 0 HcmV?d00001 diff --git "a/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" "b/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" new file mode 100644 index 0000000000000000000000000000000000000000..c0dc31c28aad5f1852563d83902838345fab2cd9 GIT binary patch literal 39824 zcmYJ*W0W0T!>!?{gKbt-Y}>YN+qP}nwr$(C?G&~ZbUNvC?>FZeUtNFa9Q~(yud!5( zYF4W@V|9WA%_}ym+O6-943-255+p>GJqZ%bXpt~Mq6C%%T{;fw*o92}0ypzl$$u_i z$$W?N=F7VyPv$&pb4$4w=Q8G+ku!15u{nNbADrErtw*-cS=(iOnWagVZ^;+Gk>0L* zoB7?&clq8Qd0+Cw`43e--uzhq)00mvKfnLn<%{~I|JU%>k>CD)i$?-^yy36(M~cI5 zDOUO={gi&-yA+YaQb-CSAo(Sq812SdM-V~Q|XEHSb8Kq!~^NRbWge~ z-9dXpv>R<_kTc4e9o|&^Ix}sTu?3~7nY02MNv#HE|-u?%B4_RE+dzf z%gN=jArx)@>zWm?%w<#!KU*u^1yclm6EoAP~rN#2Yt@>Y49yd68_ zo$@Yux4Z{?<$dyg*`BoC|L@k8YN4i7L#i%Sld7VMR9UJdRYV1;yi`sqE0w`H&wt$m z@Hq~ zzmyMor9Avs{MS7%Uyv`#m*mU1B43rS$=7j1zA4|5Z_9UZSH36TmowY`>*khnNjZ^2 z$}VM-vLcI=S;{13lrkW_luk-3rID<*|LZ=GAL5bxSbicu#WVT2{6c;yzrt(zjr>-A zhxqvax~ZkqQYtB>BuSzqz$rN-yJUk^vPfph1fyh-RCf-(oC&i9f|3;&(*Euox19VgP>8CwfH> z+~R-TcsX89pd?fhDT$FpiBXa&$&g%0q39I7Vt`RGDT!nMb=`Dbq9&@M0$Kbfeigrn zpYcijD1H#%#D{nw-WTtQckx00UpKvyUdfWK*&uhmuptrQ}xf zAg_{73B~;D-Vtx(mUvUVAzsHd@v3-5yo^iYMe%}o9_Pff;u$fmH`UDV0%0sj5^{s-uQdQ>mrYMjfTDQct;T{ja-Q+%4`BcZxg2 z?bs%66}O0+#ZA~KZV=at>#$ZlWc}BScIzt*l!j=eG*+4@P0>tguC!2EqLtEGX`{4N z+9~nz(e7HT5m$?=#FgR-EEkuFOT{HvEG`lkiVHAboF~o|=ZM+E|8?6d?UfEnN2Qa} z8C{gFN;joDdMG`WUP^EDQTi&4ywT>r?rd=uW{NY!>Ebj@6{m=k#YvbbP7ue7<1kiy z8uPE&SLvtpR|Y5pF-RG#3{i$+m@-@$p^U^RWwbIz8H@A!Xm_kQ2BXDM;z)4>hKs|* zq2dq>76*v~#R2Fq_7nSxeZ(E zqs@Qa-eNEG6nlu>#ct>-b`d*^oy3mlAhs9Vp{qAbNS zWx29KS&3E3YGsYG7VZDnZ7a4BTcefOQfwhMM>Dah*hFlMMq)#;fmk2)#Jb`J{p$bU zU8k(a24$nNN!g4o%2s8YvK>2=oysm{H})udmB%svnsvoGVr{XOSQ9nG>S8sqDyoQ; z#Y$pDR1nLH<;1cW1nnsOaC zl$**ejpca_f8f6csN9x=C=OU#KJVsSx!>|8;_nq<{AC!;EC*?D~C|{Lt3WJ5BD5|0XLTk*iF_!PeqKu`%OVI_j^$`9qI@(Z!bZzWFo zqm0)_o6&BZ@LPz*FX5-~L->w}5Eep05COq2_yjLJ!oOy;`&Wra0yUwUNKK3+YK)px zO@`!Z3RS1-VNff@`u^AT2yVdzO;81X@&#G=CVUmX;Ir^a_$Yk9d*Pk%Rwx-8?HW|0 zYJyp{s8-liyXsJ#5LA)rNlK(rQ>$szwCaD&x569YweU)Ki5J3i;TfI^PlU(9BRmuy z2=|41=%D}CjCRwh>D3I#sAf_#Ba50<&8B8Y4mGEmOU;cu>fxCC|Lfir?g+PWOSmcA z5U%5za8T*f8gqHqD{g>%AL;f*)i&7KftJ+QNjvi`HwU^o(R^NZk zxxyS_wlE7bg&D$hVH&0iQ-sNwBuo@02;(tMSQgXkf89Q6U$r0ls{_=5>L3hOhp0m_ zOdYO{P)A~vI$9m0me>DljupmWv@l8-DU85yVVE#f7=ppVAYmW|2>peALSNy(<`{LX zI!+z0PQXNUk~&$Pf~o2>bvkCKGu2t@Y;=nKui01VBlH$}p{LM8=q_|aSD}m08J&cV z!ufEtF-M)N&cl3lfw~Zj)Wzx&bt#sq%heUQ6aJU)Ahbt2p{>vct%X)XOQ8jt3(bV4 zXd<+YjpkRXtJKw4qpnrgsq3*p-KcKDW_63YRo$kR@%_s;78(f+(Lks#)I(jNj!;{u zg_=SQp*kY*TmRSCuI^BGVwbvG-GjaAK6Sr(00-4W>S6VW`Y&His47$uDx;E6QK*3O zLOG$VPzI%iQbI{#T1+$_Z5&mPsmF0bJ*l3;Y4wbHRy~LF>ILTve~B*VP-isoqj=7fZcj|k5P(P}l)X(^$epSCgRu%rw z{^c!#879Ff7@!w)LJA={k_kzL7$gyT`J#DM)!s0Te;^!==D({y@KgPz#;U&&r~Xm@B3@0PCDaljLF8Zl zyEEbpBjgM^15Q7DPOsAgx6|d+_(>lT8_g%yl4vnVswLBsBZa2Z^qK)i&7_$z$M-L< zGHsBZ-|*G>#rfI!2_KywobU0@`PTWy8HtbPEt*xcX?8d?r^e8uiJGLPL@F({mPSj9 zfBDzW*UneYm(CY>?tJEa>U@I7&PUFNc;LM6>>cwjpH54!Wk5zPla^V_f~;CLEjw~( zIkjA9V~OVPIq%|*^S1LAZaQx`uj88Ys`HBTGA=n^>;C0)Yk9Q1T0Z303TOpUNGq%r z(Tbv&R$MEgebhzs7o8WJ=W)(?)_De}ou`~9al(1rc??ILRV@GVCA5-SDXlchXl1o> zT6t8^Dr%KbS*xN|)%wI7C4a~mUE_a2BtfwIj3TZbFy<1 zCORiL$2%9rM)Q5Oep-JF&<1LQFjyO+4aG2RxHbYKwb#CX`Eky%7~>r69EFk25zgTl z<{auAg2B!~ypbcxkJ3hKV=z`5r;XPpV4^ljn~W*iRBaljYlHunALtx_{?2~RzRo`A z?d;|3i5|}G&Ti=HY#0+wPuFH>GqqWmtT!?VN3$ZJe#q%GuJ{0^4-|@=LX4+H!3LR%)xX)mWpg)z)FXwn5v7-MSXe=4j?@ z>TH6>&PL9LXyB~xtcSYJI?mdNn4|ek*sN{Qwql#MUE6`3+AeLkwg-E)ecFDlVN7jj zEoV*Ca8`F#Lse%LXJu4!R&-WCd1pCiS^Uc%&<NAqWKPCKt%z(wtnc3HcEtJ*c~ zI&Nq;wF9yL^7)+koOzvjoVk(9nbVoWnH|}jS)Eys*_p}NE)vb((r)99c2~QH``QET zAs%UuwI_J0J=1zcGXF21(U}41o#~uuk;a+YnF=YLl9S1VQ*b(+dt;xGf3CgIUgDMZ zT6=@H+B@w%K4>4cPx!3m@<-E7r^9J?+F*5BoMxDuMyCOKr_PxI$$29`lmDW9)xJU2 zl;{wlX_`xO!=rgMpXNtEi>8wysWZlz1c{xAoC%S@8SnUuKaM!ZZxoA(rUP0~3u$3Q zwC~yv{M3GFvG}dUX@BrnJ7fOsh{Z3*Psb0(cSIavM+iYjz~P6_;dOW%19j2#UoBos z;7aI9gv72St{5bBC37W53YX5M$1q(q?}p2vIhZOq6o>5ihOdqkLI%@hbyNm7jnDuxbh;OE5EA%3c3op3cGH{M$=avR~(lemvGT>!Eqkv z9A_P8aN2PSCmkmoizCr|5m!+Za}{@$KuK3AS80@Sm35Uvc~=G3`bac=!g1Vj%yAS) z9ETl;aL{qUu^;;!dmVeQ+i^X%0{M!rO0LSN;;QPZhU%^wu9~Rjs_m+Sx~@_FXnGfR zI(9g=W1C~EV+%GrHaRw8gJZp89U}2{$=7q$cQrsmS0h(rG;uX`HA8b(3s+0DagVW9Sa<(W1{KSt~Rc=u6Ahe>fq{#POi?bF6ip& z=IV|fu0G~ye!gQK<~rs$W@DCPreg-CJEmc(V~S(4ql_+^?ulNm-mX6A>*|O8t^uxr z7~~r48iJv&in__TZ4@WdV*);`IUDI6C zF~c>}H4C#{b6j&V&ov)^;-l#vj_!_bj;`q9=evT7spnWv=B|;acfhh1IS#uC=bOvC(uZv~;v^G)FT>Q%4gtb~HjmM*~NF)N|a3 zMDy!h>#@PL(X|PiU0YmRvCXyJwF5g{yIfBq(R5u$9n^NzLQO{vM|D(lRCQEAWk)4P zMSP0gMSizyk83aXx%Rsb;GpXe4!e%Hj^dc>xa+1rny%m|k8+N(jxs3iDCH=L5{}}I zVkqh;;wa1;dE9jZCtas-+I7Zt7Ux{&T^DfCb;)%ZS6o+JMgEsB|LOnbigo>V#o>?ZuPYu2+zFA$o!FfOG47=9WbXLb zXxeZ0!E5)}-Ec)G1$Nc0*x9zWf3ttJf5D$fGV;mYDWG%f-3A!lCbt$$`I~7v9(;%%oojW}; zxHGz?z;pXEJheZ;WBVifLp-qGx8K8E`yJf2-?HDdhvPH4Ga<7(i#sc_xw9jOJEuDr za=Y`m^CF)+zdM?~X}@8=Zoh`B_ABj6?Q=_5;{&f0!bgE{k&R^6m<# z=&t0hj4JM`sOGNju7R5FXDRmE_u2Pik9{|G*>~D^V7q;reJi%uH)E6im?@gB<*to7 z?z*VwuJ3MuhVDl0#%SVhie~O_NjBLx+BaameVu(R*4S6uS7D`n1(w^F*_UF8y&37| z?iOh2Zsl%`Htx3Wc4+VJfR64??#}oVzl8K+`ywo~FR;(YJo{YCvCp>8!c6-N`*c(f zbar=fcXf9|cXtnWPxNy4Mjv-ycR%!Z4{#53r}`aDPqR<8Pr+pSBuunVu#d+$`&j!J zjJA(Lq3;994{{H755Z9PFbsE(aF4_&_h|PRjCGH5m;FAcnChN}>FycsnV99C?d}nXrhB88y(fCuyW6{= ztGx?4+dJ7iqJzD?JrY0LJqL5$^W5{Xz`YQQ+>6~yu++WGy&NmtE8Wp_JG8a8vA0Gm zdrP#iH@7!KQ+pFMwl}ihNfJ%3!fN*#_gbuTug3=WM)xLcc5lH}_cr%fQzLsrdjr(B z*R$6}9eZumve&fNKy`aHRJE^35lwG*@4!y?F6?&iaqq=G_kJ93A9NqWVfV%qRqa*m zmF<;K(Ov=N?d9xcQN~^xrR*i`CD6@unDi0%Q5cYkny#3%P> zd~ttue}n8++=ISH(^E~Y)5g#b{O9RB|IfO zB|W7)rBTLH7UewUJrz*VQwfzlRZ#EuVcQ|wK^(B{$3EL$+aBz;?ZQsm4s5q=vu(Al z`Cipi4b?q0P}5V(QyX#@$Z*0u(#ZL6@- zw!&8DcOy?@PZKotG(&Sw3$*mK^0Y=9Pg}I}wD%+nt{}bKwhT*cOKgj=$hHs*Z1XYC zHrF->vw0)idpdYJqLZhyrwh7zx}m$L2YPyXd3vLdr>|!==~=d!wi%din}(^jDYnU& zWSfWyw(%HeTb?AE?&s-`0iJ;vX`(HLbLX&Zsz zwqY1*8-l^MLAHS?lwuU=(Vj6F>lx=6j|rZMnB%A$;|wC&ON{hkA!gE-_lj3b_-IOaKy6P}ZvQ#kE8<9YMFH0e^d zk|<#-j$*c=wjwBOD};i!0?2R6XY2O+jOVQ99L{?#;G*Y}=Q6H%uHu^KI&OGwdYT3E z+43TfEjM!6aw3NcbkXG?+Pwq*E|{EO!+zIix2#in+2^Fs45jqtcV9*-A3 zj~@X~!W2nuF-T%dY)gcMwgiZ`{>2|_9DZA4t-q`zi~&y&Ay3#7!FSIO{Pg@ntmil4 zJbyfYF*?aF>rd+se78mrwuTV22CRPgtX_DmObV=jJ@K9d-UQx+-b6_3O@bJ2Qg1RO z_ojf(t4CbC+v>7v(amfQKyhT&`pxr*_jKE@;KLu;}hDZQz@sgcH; z7U{g{y%~_vn+ch{S&-G6&1?Pf(E0%Pt@m)(dIz_yw{X*X1J|wBaMgOnI`MZlZ+34E zZ%*X$=0+ZGUgY!UM*(j^6!I4KZVO(qUdAQsMO?6+x1PgU>lvK3p2A7%2^_aZ;){5T zc#C?Ap}4mMN_tCqOQVdpEXsMyqk^}h_qg?#^(c;5595&aAP!jfW1n>|_E>jYccD{~ zirz}7?5%>T-fF1st$~`}TBz-<v`*=fwv(Vc^iA1psBYRntNNIrMDGYd+#J)V_j`sg_YJ7SZ-a0rPd`_Y+Zzf)&w=ep6`(uE2AO?8{V~BSs;^HS-Ct4?1$77szEXG(zW0Z9yMp%bi zhheC7i1lf3n0L5$1V(yCVYGJ)#(KwLymx|kA|`n!d)NIQVjYY@)`1vc?T>!ezUX7^ zjb7HC=wa<{E%RfFcZzqacN(U9XJDpx7G`_rV6Jx_=6e@-tNrM1?S`(_F6eCSgpSq@ zXm4$2ZHqS6)@Ws2^LwFpp?8sYF_w6jVwrb2R(Mx>S7Ehx4c2-i!B*Cm))r`PZHA`S zCTMJJgof4zsBf)@y1bF=yz8;SyAhkbo3X{a72CYqvBSF)yS%%-b**)*wNcAj6E&>W zQO#P_S_PG@l~B=I0o9Z2_U^%6?>_AJ9>78GA@5-v@gBu7?{S>)`V8f*<*a2<##$Ps ztR+#xS{%i!MN!0B7>kpi@SgOZ!fEdrob{f=dG7^W^j^Yc?-gA2E>B*_TF_bm`K|eo z*P6$g8@a4Gk;9rD*{oTuFAZ0{*Syzp!+X0$60Ve*+_@i8d*Y%Fp) z;Pid6e6oDBe879lJG`~L!E4JaytKT)bIUX2P2%K$7Ked-k}oAv`BEc|FD=sf(j$W} zBQp8c8=hL8SRUh%EBoy5*YXDy~>AZ^w8 zz8a|MtL2M}-)q@}-IiV0Y1x78mTlN-*@DfMP1tDJU>O>!?W^spBwHzHaF5>w%uWUg+)X zgTB6g=G6aJygD}uC0R1if(AUxj_9PR06MPeWlQ7vg1yeE2 zHyty4Gcn6I8*_a14ZSSAEIlnf(B0AvUD3tT8J#R0(ZSLlxsuQI&GpUm&Bp@YLM-wv z#uDFBEb}eL3g1fPP2Seh*3!n(8m%lX(ZbRk%`8pP#L^gzEDbHC46A&rvBtL+>wN36 z!M72ce4DWaTYcMLP13+pAN4GCQO8mnwNTSi1Jy0nP}Nce+kHE*)3*z|eS5Iiw-5V$ z2XN4L2#0+~cq1!YDxspK0?J#;p{%70N?S^yq@@IkTZ&n(hK~A<;kfSvPWn#awC@bg z`p)6J?*cCRJim)sidu@Gu%!?RS_&XP@>%jCk0m#9S#nxl|G4D4?7M=ixaPZ#8@`*k z<-3hLzPq^R`}!lNC5I(DvRSesizPEMSu!GnB|Xwv(jtw;{rkS}f$t$6`5xnm?Q_+yU4Z*weu;ivfrzMCUa$$t2L;+HQLzkPA|Y9R?M&?TlKWFY=hwsF zH^Stn?zb>``JVWj`76GdKjV}6BR-hl;~n0b-{7_R6|5|X+2QaHQh}8Ij4K8Cj6kpAFgl zIgrzz3%UKtis3NGW4`64cu&zr43^Z4`n^C7>#01EmGp|HOQ ziu#M8I7;|a{XAzrXFh8_gVQ)=K8X|N<2YtMiX-O3IAl&5SIS??U)o;=W&Pz)9u@o* zQORE!Rs2;^IDF82(0stWAN$OEvB$g{yUaVW1KZ8pu+9sj(IjNBFYzskz$E<^JXV75ZiLp`p0}>Z6{yF6x+Tqn5cQYM3*|ZSil#Hf;Crz)t@z?Dp@$UjIJq$K-Hzb2U^& z6?0`&GFLyEz-O znzJA?GMO`CcCtJEJN~=4=f95!{)c$ve~c%1>VJmk{ulmv$UdYk-!{p ziZ}hmA5$EDn_}?`N5Wx$*dOtK#}EHc{6eh%H{$$%@Yf%YvT;95KTO|E5rh#k1radu zQ*UBYVDd({Bus9T%QX6D!ayP<4kSSgk_M6?c_0OJ&<6~d@{@zMT)N>t?3P3n_l51 zUYMTandvE>;IZiu9-6+#rw*hEq(!uL*P%=;orBNnO7UcrvQ2`YLl>#l39W@=nVbdWTG#$Wx>@)4f z9@B2@LY%I0pbDx6s-Ze+1ZtvIpf>8DZlE6O2ev2PVcKrmhOMS8*o;l4jo5(orgd0r zT7w3GhJi+Cj3$AmXclOW7HAo0h1O^jh>KrkT4h>^6{h7_W?G6RSZrE^g{B3Vk0gmL2I-!%}=~Iumnr7EU+9a0xPi! zs{?DW7V84*0~d8IOwG~E)D%t7*whFO(ZEz6^-OhPD%jF2&i0Q|-wiT?!tB0i8Hm>`%imAStD06 zjH;2#;L#6=7{3|6;*0S!+(~soeb4}7bU2DmU4oV%8z(`w*g3ex0pB1OL4rcim-M6Y zgYiAy;jQrvUgMSVC0^jU@fq6cq8~U(8BB%LNE1wpbVwh}fQ-l#%#1;a9~&PVAK{_# z0q*0T@hA}tF^J3ZpS5 zI2PkDJ~#oVk`6ZxGY-WN3^oqJKnyVUM?ds6_JK(^DL5HZFf}+0(=j7B6SFWoI0tjl zJaJEBPh$^sH+Dl;bTM{DCv-G+Kzp<^&JQlY!r&q-#**MtEW`5P3arGc;A-B;HpVu_ z*2Y$7i5AA@XojZ7CTNUC#)b$*)&|#MJvIb4ViPt8w_q!_1-D~IaC=;RV?ESG9b;|O zLQP{0R7W*bHC91`U%P_4u_w3}`>;QF00(g>co;`;G}!W2C1XWYFqTI-lr@$?X_P`q zV+j<;_PFE06Ty=>h10<^IE!<^^SFSE!Ar1yFKR4eEQ~@ZXe@yI$cMbfJjjh)#+=3< z@mGUagV%69cmp?aD|j1sa5s1l_wgW@-I(2&4Oxv@kQtef(U<}0k__jU0h8+Kg;oM3*;#1DKoL=ZN_24jQ25f}V} zzlcYIP(ma^;!qOAgyQ0(KlcFx3rz+eywL+u(WA45=rs$?z*TNT^wS}-q4M`+q2!?y z&_NGFh~X&2)Fot&Zk$B7*g1@KDekM`3qIqM;UhlaJ>D7K;tgIKUSaAlt`&y3rXCVQ zdM$QH_u z9LR}Wq1?!Wj^A$^ZW(Uk2Cf^f;VQ1+vf&ag;sSoe=MNP?K@-k zoHd*^oWW_sDV)R!95)=pQ5?Zx!y!Yq#AQNdQ7%*-6;Lr$36)U=RYTQK9g|ZWFzh$% z!(QycZo@9@#16xDY{OPVy`(imwL-N~2X#aBP#+D@Fw_W*(InI~)FA05!zRN^a=GvKlJB~oMD(@m~NPcshDDzj7gY?35M|) zhp~pm-v@;Tg$83tXefqZcxVJhViZP)#^6odXu~Ls#0bN148u?iF$~5a48#D#>0jeR z6EG2zFgY{@Q!x$GLo+ZFmw)v$^hF;-Z}dV>^e}WsH*`f8Lub5+n-iLgd6*wsfQ48T zT8t%Fie;hYp#$GL8af!-qaE6!jiEJKp{1b(nxh$h#IFpk3a!Q(ti`&}dThW(Yzl40 z7Bn_AHZ(#*Lj%-DJ=8VSL2cARO+yWXKJoU@_RtRO#IDe8?7`m9KJ3Q<91I-_6-iOe zP}NWcl~D;54HZxxZBzMB@D$; z3`Gq^P#A?!&`<#Rk`ukkgO@*^v!d z4Ox&GnUK+t!Ei5h9}hwg@d%IcB=i)|@Ek8fFA*1?&X5*qkQ%8BDZvM%41C(dAQ+-o z^Bk}nK7D@^dW&~>j}M`b_=L~+68eg7_z`C{SirIr6AlImL-f%@XGnqMNQR^a|1UMf zg}jg}6g^7I(Wej_+aX`b52g{(AtDqCg+o7oB{3vMB11y3lVRXXQu@F8KZwI`#Oi_oZv2Q( z5>671LDFzCBu5J9qQx+SQFP+MB9}SLr$O`zWc@dM#TR_WC;dl!zKIJVI0Ci z9Ke44KJ3LF?AGtXPJN*t)xy=oHBb|^P#bl^bx{xX(IDI~Z2hxczYSZlMZX!Fun`-u zUcU}&u|}UYwn?~2xG9>UdAJ2yq7_<)+n_Dlh4aR)(yzn{{ckvEgwTj|rHFN#V(u zf~lAmUYdN6exQB;`lBEEqL02edZ8zJV1LZa@XYWm%*Gtd4bQ`TEWko63NOaNm@fJ* z`p)Qtj`|L0k9KH_Hu~0RrJtC5S$J7^IaY*MVii_n4c3O&VLdkBaiZq>=K5y(rf7o3 zXoQAnps$a5sEf_vE!c`}*p40Ho!Eul*n_>{eY}yi^fgff)lm&q^;J+Al~55C^yT%F zejE%R3?IVb@DUuvF&xK<@JXD)>2RJuW%XrH8m06lQ3AzL3`J2yUl@h-tz*xH&*K6v z;u0>0uiz@K;W}=FJH-~z=hx>$UgSY;Sb31$ocX2O#9}n;lkMKDB z1W&R4M<#tHeMV$JdZg2*MH-|=Dx`#@{~7-x{4)Fsuki+N@hM@HxyS@93pG zy+n=hqcVo*Hw+_XVF}jBc4k%+Ui|(WAZ5OCZs!g%K{HN1PD>d@3;_MdIQ==-%TU-r@~jmK1D9^k(29`51}ZsQhi>Tc*3#O8?P zM6O6~AxP*(ifb%$~JBu?ot*id0Sfp5_I7*--N}+V5 z49cP$%ER~LgzkjyIF8{cj^MEF5Dwx1_G6#!XME*I?vWmm>M;v+3v}}_4|6d`Hyg7s6EiR!pOf{D z^p5mFU-UzN48Xw1APmM342`TyG+8%UHwhDU6EGg*FcxDl8l!Y0F(NV|G7_UOIx+@h zF%IJ~0TVHaH*%P6D28A#24Nrupuesk`l1gq{hSt=7MYG2n2A}KjX9CIn1}i3_@}3? z2fCvhy6U>1GdiIoI-otS#x9O5jx51aEQ>713arE`ti~GLjcucAt!ssrXo2QvhNftO z#=1smsO$7+Lu4a1VKcU1E4E=fc0_h!SET&Udb)bLx~PNNsD+xSp{tH+sER7Qk$WTi zupb9-5Qid%aRf(k498JXS5a31P!I)>ANi0Md5~L|3)Yw$ksFblxP{xegS(M?xQ_>Th({4yOg3FM zT~=g4W@JJ}T?V8_I;6$oWKSbcBhTCC!MkSr{Wpc~Kkz$#!>{-SKjSA5SQz^Oqx{#@ktNP0 z7zGoG>+v`77T(4?_&eT>&rZCIy^OuUb3DUSJi%i;!o%1D+>a&058@xh(;zLR5YMdC$K48>6bB~c2cQ3fBOZ2XaRICeO82nTTh`>_vu zu_v}0yHGV+Azl%cP#INFHC_!LqdGo84RklQ#kR$^VoPi@Hen++U_I7hE!MxVp{`LaRxMT)RZtm~Vii#V!*RaIQn8XKf#N8Jq9}sGD1?G25PLg$I(|BS24`^&=WziS zaS4}kCBDPY8_R>-$c3E9f$YeJ50MpFVx8UV@$2y$xQSc19lwLSxQF|AfL?CKSO%m= zx>#DI0RiPPVrOIT;U9PxJN+l|r+9|vc!8Jr95s~SO42UcJ4^kea+{ZoK#U0$nE!@NnTt}AFtoRVwkR3UY6S+B~TKjaLGTOatudt1mEH?4&fjUU_bVy zEOE=GmP2_|Kt)tSWmG{`RKqHFcgim8#13r7Hf+TfY{n*ROu6jWOs$z(3!kDk>Yy&_ zp*|X*Ve07Q+LW~^Yp@#M;A^bHO02+gEJHGEn%XqA8JeR7TA~$NqYc`k9Tul7PWcjx zun=Eh0p{a#%)?w{GCHMpMi+ENH*`l2^c1~Pd!_b9x5$i?87b2-O-xOhnlc5GF$ohf z0p+d!sRJ+&gD@CFFchC*7=~j+YI$pP%IK6)7>N-Wj$!x=Looz{Q<_9Zr;fo`jKg?L zz(h>KWK6--)U-zbl>RCG&=-Bs8@MYF09L&W$e2)28fG^;Ool-iY z1KOh<+Mg8nFl(H!wp$tl+6iT85ii=_?#Zro9_f%4 zX%Mp>ranx4gvWS-r+9|vcp+YRlHY}JhphH6#4Wj|#scI#VgacQ2lr?+=2;qlL zG>L@Z$3MzZLR~tk0!8#s@Hi@}9@6TLm9P>vB2a&x0(D$bms_tx71#fk(1Tx9z*Lmz z!YCoPDk@!#*N8HdDf$ZEij+@D� zODw`dd?6M@+b7y5I*5*mj)_j_j4tSkZs?942|t`0og-#PXGdpYCT3tdreP|kpuW>5 z(HH&D9|JHDgT&y(;KUFNO{5PdM8{(s#$pUci&4>0(UBN|;rPfKnNUVyG{#^o#$h}r zU?M7cL!yH*2m>(y{m~D7(MR--_Ks!@rX{8®+XVisnLIf*%mxtNFEPPb^cXjgO* zoui$jozM{-&>rp3mM?N)VqsztzQkgDg(X;uWmt|CXc=u8ZGq-!hNfsD8b=#P8=)Z@ zM6c=JBvxY$)?%GlpID#RfQ{IM%@}UiiPpxasD+xSflp8!AB$?y73TKD4(!A(?8YAK z#XjuE0kOiY7_AtsfbybTv|O|-K0+CkMk#c&k0g#Hj^Y@OixY_xiIX^m(>Q~(iHmyC zXwhg96hx_*372sNS8)yB;kvkyxRJQY7nviPU1W=9i++f# z$b!tsgp49Xbd_^2aW8Qn55&X7!^9&z#uGfnGn5F@L>Y(_Wz0pC7;~uTS%L*sY?KNG zQEl8&n)IUmyjO`=2?ih~7;2FqiabHkY=RiU1hG#b)PuM=h8r9sH?^73v}iaj0!Vtt1?{@DhieAQD7;;YGZN3kON4Eg)5>%O^L~k~$2S zuwX;}RBbNF6sXHBS~67go@xk>5FQdCW(outMu@tN5Q-jQxJKlM$X;DFiO^1{TF*hK zi3-0;5|fxDPB%$Rdy*j@$YZ~Zyufoj!&5xLWAP~RDDn^wB0bG?$#lu|$bgK$eYZY%!m9afPyF_3MY5y z=OgDM=WrHha9W&-oQj;p2^`0P$RQlW0qn;< z>=k<=dm_8BEAlU=O0ue`maLZi7}fC!YM`d5m8_MlA8d!Sf0qLFBvY@BR@rf7!d$xYtZkyTiU6sUZ5QCCa zB2yxhF$ojJgvf-*c#OkXj1f!q&yvH$@Z|912#mxij22^(W0GSrF8RP39vOzuFcd>D zSPY5`iVVa6^pBJ_KNh1^b|cJJtE!F4P8Z-NS8=w zbc$TC<|mZ}_yP;D2w#fD$;HX9umlVBwvo1xHfW7jqGhCIqy?IznP?hm8d;fKnOudh z@r_uWT%BBlwOEJsA_yBq8bs=&9_pfws2!;t`4qKK6Ez~0+^tDv8@6Kyc8XoeUCG_p zgT2XKLA6L#R1uXUl_Ql<5fw!FNcl)Pl#M*_4kZsI593>LBzYux6vuEJC&bC*Q?FE{ zBubz-iix6;qLCsfj6$Mdq)%`zc`kV#7jO}m#O37WV-o_nqH+eUC5BKo^55=S8qvYdchDe4;dZZI+BWWXP!0@~XBay(+iwGg* zk#YL-CC@QI?fDpw@qLLDa5HldkDMA4xkv_m@(zU|u{TsZb{%TM~r03kF-b97-i zhGW74e}<8|DtYrX%I2{xRBHw;d^YXgHW4BqZptQ@(0yHU$HOa5-y4h z_67Sq&f%;$W1q24+tY#~PEiy?ag-1xosv!|lon;2GR|iAn5`Ve5qv8S+lTE#IEVvc zzwL$>*bD6W_#E>@UaPCq4c*a0^mKYUz0ezdL|><`)6Z$I&#-6M z)5SD$JbPnM#z7&^|_6t|pP&TZTgcb&V=J={liJ&T>i&MY$7ne2?nAky3E?Q}>h z(%4U&r{bCO%z2I%;-&M_`5v!?8mJnCD#-lrAXX46IEqjZ`%R(R^i&JR8z|%v9q14Y z1r_H(tF|MJc&dRyJ&sjRpKv&c+7V&1EMeNFZ9o^At=XX!S|fai<-&t60w-`nXs)8e zaL4+NI#8&~C!~V1x5X1F@SIk)XMkot>u3;Fr4dz1$7@i}d8PTorB%?S{Ty$)wY*DE%KBb-Aw!|?9^MxpxF5J_kXEE~)4A!9!F^&qu^x*@)+6ho zcwjxS?&F@gYu&Z(SVi@$ZdUh0k` zb<3f=sNhy`D~d{PCATuFh^lT?H^08m+Gp(*d#pXyZn4YSW$nZcvEABkZL?~+N-caU zYP+@FI;bn^x%J%oA_zBH8?6mkFV

thHi|wZ>YFZ^YNuSg)zuOf+|!yDiXCv~pXy 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 HNLc?5T?xoE literal 0 HcmV?d00001 diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index 55220cbb0..b8b697ff4 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -1,188 +1,375 @@ -import 'dart:async'; +@Timeout(Duration(minutes: 5)) +library; import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_tizen_example/tabs/sources.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -const String _kAssetAudio = 'nasa_on_a_mission.mp3'; -const Duration _kPlayDuration = Duration(seconds: 1); +import 'lib/lib_source_test_data.dart'; +import 'lib/lib_test_utils.dart'; +import 'platform_features.dart'; +import 'test_utils.dart'; -void main() { +void main() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final features = PlatformFeatures.instance(); + final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + final audioTestDataList = await getAudioTestDataList(); + // Tizen: remote URL/stream playback does not emit reliable complete/position + // events on the device, so data-driven tests are restricted to local asset + // sources. Network-based test cases were removed after on-device validation. + final assetTestDataList = + audioTestDataList.where((td) => td.source is AssetSource).toList(); + + testWidgets('test asset source with special char', + (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(specialCharAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); - group('asset audio', () { - testWidgets('can be initialized', (WidgetTester tester) async { + testWidgets( + 'test device file source with special char', + (WidgetTester tester) async { final player = AudioPlayer(); - final initialized = Completer(); - player.onDurationChanged.listen( - (Duration duration) => initialized.complete(), - ); - - await player.setSourceAsset(_kAssetAudio); - await initialized.future; - expect(player.state, PlayerState.stopped); - - final duration = await player.getDuration(); - expect(duration, isNotNull); - expect(duration!.inMilliseconds, greaterThan(0)); - - final position = await player.getCurrentPosition(); - expect(duration, isNotNull); - expect(position!.inMilliseconds, 0); + final path = await player.audioCache.loadPath(specialCharAsset); + expect(path, isNot(contains('%'))); // Ensure path is not URL encoded + await player.play(DeviceFileSource(path)); + await expectLater(player.onPlayerComplete.first, completes); await player.dispose(); - }); + }, + skip: kIsWeb, + ); - testWidgets('can be played', (WidgetTester tester) async { + testWidgets( + 'test url source with no extension', + (WidgetTester tester) async { final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } - }); - - await player.play(AssetSource(_kAssetAudio)); - await started.future; - expect(player.state, PlayerState.playing); - - final position = await player.getCurrentPosition(); - await Future.delayed(_kPlayDuration); - final currentPosition = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(currentPosition, isNotNull); - expect(position! < currentPosition!, true); + await player.play(noExtensionAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); await player.dispose(); - }); - - testWidgets('can seek', (WidgetTester tester) async { - final player = AudioPlayer(); - final seek = Completer(); - player.onSeekComplete.listen((event) => seek.complete()); + }, + ); - await player.setSourceAsset(_kAssetAudio); - const seekToPosition = Duration(seconds: 1); - await player.seek(seekToPosition); - await seek.future; - expect(player.state, PlayerState.stopped); + testWidgets('data URI source', (WidgetTester tester) async { + final player = AudioPlayer(); - final position = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(position!.inMilliseconds, seekToPosition.inMilliseconds); - - await player.dispose(); - }); + await player.play(mp3DataUriTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); - testWidgets('can seek with different playrate', ( - WidgetTester tester, - ) async { + testWidgets( + 'bytes array source', + (WidgetTester tester) async { final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } - }); - - final seek = Completer(); - player.onSeekComplete.listen((event) => seek.complete()); - - await player.play(AssetSource(_kAssetAudio)); - await player.setPlaybackRate(2.0); - await started.future; - - const seekToPosition = Duration(seconds: 10); - await player.seek(seekToPosition); - await seek.future; - await player.pause(); - - final position = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(position, greaterThanOrEqualTo(seekToPosition)); + await player.play((await mp3BytesTestData()).source); + // Sources take some time to get initialized + await tester.pumpPlatform(const Duration(seconds: 8)); + await player.stop(); await player.dispose(); - }); + }, + skip: !features.hasBytesSource, + ); - testWidgets('can be paused', (WidgetTester tester) async { - final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } - }); - - await player.play(AssetSource(_kAssetAudio)); - await started.future; - expect(player.state, PlayerState.playing); - - await Future.delayed(_kPlayDuration); - await player.pause(); - final pausedPosition = await player.getCurrentPosition(); - await Future.delayed(_kPlayDuration); - final currentPosition = await player.getCurrentPosition(); - - expect(player.state, PlayerState.paused); - expect(currentPosition, pausedPosition); + group('AP events', () { + late AudioPlayer player; - await player.dispose(); + setUp(() async { + player = AudioPlayer( + playerId: 'somePlayerId', + ); }); - testWidgets('do not exceed duration after audio completed', ( - WidgetTester tester, - ) async { - final player = AudioPlayer(); - final initialized = Completer(); - final seek = Completer(); - player.onDurationChanged.listen((duration) { - if (!initialized.isCompleted) { - initialized.complete(); - } - }); - player.onSeekComplete.listen((event) { - if (!seek.isCompleted) { - seek.complete(); - } - }); + void testPositionUpdater( + LibSourceTestData td, { + bool useTimerPositionUpdater = false, + }) { + final positionUpdaterName = useTimerPositionUpdater + ? 'TimerPositionUpdater' + : 'FramePositionUpdater'; + testWidgets( + '#positionEvent with $positionUpdaterName: ${td.source}', + (tester) async { + if (useTimerPositionUpdater) { + player.positionUpdater = TimerPositionUpdater( + getPosition: player.getCurrentPosition, + interval: const Duration(milliseconds: 100), + ); + } + final futurePositions = player.onPositionChanged.toList(); + + await player.setReleaseMode(ReleaseMode.stop); + await player.setSource(td.source); + await player.resume(); + await tester.pumpGlobalFrames(const Duration(seconds: 5)); + + if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { + expect(player.state, PlayerState.completed); + } else { + if (td.isLiveStream || td.duration! > const Duration(seconds: 10)) { + expect(player.state, PlayerState.playing); + } else { + // Don't know for sure, if has yet completed or is still playing + } + await player.stop(); + expect(player.state, PlayerState.stopped); + } + await player.dispose(); + final positions = await futurePositions; + printOnFailure('Positions: $positions'); + expect(positions, isNot(contains(null))); + expect(positions, contains(greaterThan(Duration.zero))); + if (td.isLiveStream) { + // TODO(gustl22): Live streams may have zero or null as initial + // position. This should be consistent across all platforms. + } else { + expect(positions.first, Duration.zero); + expect(positions.last, Duration.zero); + } + }, + skip: + // FIXME(gustl22): [FLAKY] macos 13 fails on live streams. + (isMacOS && td.isLiveStream) || + // FIXME(gustl22): Android provides no position for samples + // shorter than 0.5 seconds. + (isAndroid && + !td.isLiveStream && + td.duration! < const Duration(seconds: 1)), + ); + } - await player.setSourceAsset(_kAssetAudio); - await initialized.future; - final duration = await player.getDuration(); - expect(duration, isNotNull); - await player.seek(duration! - const Duration(milliseconds: 500)); - await seek.future; + /// Test at least one source with [TimerPositionUpdater]. + testPositionUpdater(wavAsset2TestData, useTimerPositionUpdater: true); - var isComplete = false; - player.onPlayerComplete.listen((event) { - isComplete = true; - }); - await player.resume(); - await Future.delayed(_kPlayDuration); - expect(isComplete, true); - - await player.dispose(); - }); + for (final td in assetTestDataList) { + testPositionUpdater(td); + } + }); - testWidgets('receives position updates regularly', ( - WidgetTester tester, - ) async { - final player = AudioPlayer(); - final started = Completer(); - var count = 0; - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); + group('play multiple sources', () { + testWidgets( + 'simultaneously', + (WidgetTester tester) async { + final players = + List.generate(assetTestDataList.length, (_) => AudioPlayer()); + + // Start all players simultaneously + final iterator = List.generate(assetTestDataList.length, (i) => i); + await Future.wait( + iterator.map( + (i) async => players[i].play(assetTestDataList[i].source), + ), + ); + final playerStates = List.generate( + assetTestDataList.length, + (index) => null, + ); + await tester.waitFor( + () async { + // TODO(gustl22): Improve detection of started players via player + // state. + final unplayed = playerStates + .mapIndexed( + (index, element) => element != null ? null : index, + ) + .nonNulls; + for (final i in unplayed) { + final player = players[i]; + if (player.state == PlayerState.completed || + player.state == PlayerState.disposed) { + playerStates[i] = player.state; + } else if (((await player.getCurrentPosition()) ?? + Duration.zero) > + Duration.zero) { + playerStates[i] = PlayerState.playing; + } + } + expect(playerStates, everyElement(isNotNull)); + }, + ); + await Future.wait(iterator.map((i) => players[i].stop())); + await Future.wait(players.map((p) => p.dispose())); + }, + // FIXME: Causes media error on Android (see #1333, #1353) + // Unexpected platform error: MediaPlayer error with + // what:MEDIA_ERROR_UNKNOWN {what:1} extra:MEDIA_ERROR_SYSTEM + // FIXME: Cannot play multiple players simultaneously at exactly the same + // time on Android Exo Player + skip: isAndroid, + ); + + testWidgets( + 'consecutively', + (WidgetTester tester) async { + final player = AudioPlayer(); + + for (final td in assetTestDataList) { + player.play(td.source); + // TODO(gustl22): Improve detection of started players via player + // state. + PlayerState? playerState; + await tester.waitFor( + () async { + if (player.state == PlayerState.completed || + player.state == PlayerState.disposed) { + playerState = player.state; + } else if (((await player.getCurrentPosition()) ?? + Duration.zero) > + Duration.zero) { + playerState = PlayerState.playing; + } + expect(playerState, isNotNull); + }, + ); + await player.stop(); } - count += 1; - }); - - await player.play(AssetSource(_kAssetAudio)); - await started.future; - await Future.delayed(_kPlayDuration); - expect(count, greaterThanOrEqualTo(2)); + await player.dispose(); + }, + ); + }); - await player.dispose(); - }); + group('Audio Context', () { + /// Android and iOS only: Play the same sound twice with a different audio + /// context each. This test can be executed on a device, with either + /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode + /// the second sound should not be audible. + testWidgets( + 'test changing AudioContextConfigs', + (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setReleaseMode(ReleaseMode.stop); + + final td = wavUrl1TestData; + + var audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + //ignore: avoid_redundant_argument_values + respectSilence: false, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.play(td.source); + await expectLater(player.onPlayerComplete.first, completes); + + audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + respectSilence: true, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.resume(); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }, + + // FIXME: Causes media error on Android API 24 (min) + // PlatformException(AndroidAudioError, MEDIA_ERROR_UNKNOWN {what:1}, + // MEDIA_ERROR_UNKNOWN {extra:-19}, null) + // FIXME: [FLAKY] Audio Source sometimes does not play the second time on + // Android Exo, despite resume event is triggered. + skip: !features.hasRespectSilence || isAndroid, + ); + + /// Android and iOS only: Play the same sound twice with a different audio + /// context each. This test can be executed on a device, with either + /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode + /// the second sound should not be audible. + testWidgets( + 'test changing AudioContextConfigs in LOW_LATENCY mode', + (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setReleaseMode(ReleaseMode.stop); + player.setPlayerMode(PlayerMode.lowLatency); + + final td = wavUrl1TestData; + + var audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + //ignore: avoid_redundant_argument_values + respectSilence: false, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.play(td.source); + // Low latency mode does not emit a complete event + await tester.pumpPlatform( + (td.duration ?? Duration.zero) + const Duration(seconds: 8), + ); + expect(player.state, PlayerState.playing); + await player.stop(); + expect(player.state, PlayerState.stopped); + + audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + respectSilence: true, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.resume(); + // Low latency mode does not emit a complete event + await tester.pumpPlatform( + (td.duration ?? Duration.zero) + const Duration(seconds: 8), + ); + expect(player.state, PlayerState.playing); + await player.stop(); + expect(player.state, PlayerState.stopped); + + await player.dispose(); + }, + skip: !features.hasRespectSilence || !features.hasLowLatency, + ); }); + + group( + 'Android only:', + () { + /// The test is auditory only! + /// It will succeed even if the wrong source is played. + testWidgets('Released wrong source on LOW_LATENCY (#1672)', + (WidgetTester tester) async { + var player = AudioPlayer() + ..setPlayerMode(PlayerMode.lowLatency) + ..setReleaseMode(ReleaseMode.stop); + + await player.play(wavAsset1TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + await player.play(wavAsset2TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + player = AudioPlayer() + ..setPlayerMode(PlayerMode.lowLatency) + ..setReleaseMode(ReleaseMode.stop); + + // This should play the new source, not the old one: + await player.play(wavAsset1TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + await player.play(wavAsset2TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + }); + }, + skip: !features.hasLowLatency, + ); } diff --git a/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart new file mode 100644 index 000000000..e902403dd --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart @@ -0,0 +1,141 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_tizen_example/tabs/sources.dart'; +import 'package:http/http.dart'; + +import '../platform_features.dart'; +import '../source_test_data.dart'; + +/// Data of a library test source. +class LibSourceTestData extends SourceTestData { + Source source; + + LibSourceTestData({ + required this.source, + required super.duration, + super.isVBR, + }); + + @override + String toString() { + return 'LibSourceTestData(' + 'source: $source, ' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} + +final _features = PlatformFeatures.instance(); + +final wavUrl1TestData = LibSourceTestData( + source: UrlSource(wavUrl1), + duration: const Duration(milliseconds: 451), +); + +final specialCharUrlTestData = LibSourceTestData( + source: UrlSource(wavUrl3), + duration: const Duration(milliseconds: 451), +); + +final mp3Url1TestData = LibSourceTestData( + source: UrlSource(mp3Url1), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 77), + isVBR: true, +); + +final m3u8UrlTestData = LibSourceTestData( + source: UrlSource(m3u8StreamUrl), + duration: null, +); + +final mpgaUrlTestData = LibSourceTestData( + source: UrlSource(mpgaStreamUrl), + duration: null, +); + +final wavAsset1TestData = LibSourceTestData( + source: AssetSource(wavAsset1), + duration: const Duration(milliseconds: 451), +); + +final wavAsset2TestData = LibSourceTestData( + source: AssetSource(wavAsset2), + duration: const Duration(seconds: 1, milliseconds: 068), +); + +final invalidAssetTestData = LibSourceTestData( + source: AssetSource(invalidAsset), + duration: null, +); + +final specialCharAssetTestData = LibSourceTestData( + source: AssetSource(specialCharAsset), + duration: const Duration(milliseconds: 451), +); + +final noExtensionAssetTestData = LibSourceTestData( + source: AssetSource(noExtensionAsset, mimeType: 'audio/wav'), + duration: const Duration(milliseconds: 451), +); + +final nonExistentUrlTestData = LibSourceTestData( + source: UrlSource('non_existent.txt'), + duration: null, +); + +final wavDataUriTestData = LibSourceTestData( + source: UrlSource(wavDataUri), + duration: const Duration(milliseconds: 451), +); + +final mp3DataUriTestData = LibSourceTestData( + source: UrlSource(mp3DataUri), + duration: const Duration(milliseconds: 444), +); + +Future mp3BytesTestData() async => LibSourceTestData( + source: BytesSource( + await readBytes(Uri.parse(mp3Url1)), + mimeType: 'audio/mpeg', + ), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 76), + ); + +// Some sources are commented which are considered redundant +Future> getAudioTestDataList() async { + return [ + if (_features.hasUrlSource) wavUrl1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(wavUrl2), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + if (_features.hasUrlSource) mp3Url1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(mp3Url2), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasUrlSource && _features.hasPlaylistSourceType) + m3u8UrlTestData, + if (_features.hasUrlSource) mpgaUrlTestData, + if (_features.hasDataUriSource) wavDataUriTestData, + // if (_features.hasDataUriSource) mp3DataUriTestData, + if (_features.hasAssetSource) wavAsset2TestData, + /*if (_features.hasAssetSource) + LibSourceTestData( + source: AssetSource(mp3Asset), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasBytesSource) await mp3BytesTestData(), + /*if (_features.hasBytesSource) + // Cache not working for web + LibSourceTestData( + source: BytesSource( + await AudioCache.instance.loadAsBytes(wavAsset2), + mimeType: 'audio/wav', + ), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + ]; +} diff --git a/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart new file mode 100644 index 000000000..552f080e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension LibWidgetTester on WidgetTester { + Future pumpPlatform([ + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + ]) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + // FIXME(1556): Pump on Linux doesn't work with GStreamer bus callback + await Future.delayed(duration ?? Duration.zero); + } else { + await pump(duration, phase); + } + } + + /// See [pumpFrames]. + Future pumpGlobalFrames( + Duration maxDuration, [ + Duration interval = const Duration(milliseconds: 16, microseconds: 683), + ]) { + var elapsed = Duration.zero; + return TestAsyncUtils.guard(() async { + binding.scheduleFrame(); + while (elapsed < maxDuration) { + await binding.pump(interval); + elapsed += interval; + } + }); + } +} diff --git a/packages/audioplayers/example/integration_test/platform_features.dart b/packages/audioplayers/example/integration_test/platform_features.dart new file mode 100644 index 000000000..75d754001 --- /dev/null +++ b/packages/audioplayers/example/integration_test/platform_features.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; + +const testFeatureBytesSource = bool.fromEnvironment( + 'TEST_FEATURE_BYTES_SOURCE', + defaultValue: true, +); + +const testFeaturePlaybackRate = bool.fromEnvironment( + 'TEST_FEATURE_PLAYBACK_RATE', + defaultValue: true, +); + +const testFeatureLowLatency = bool.fromEnvironment( + 'TEST_FEATURE_LOW_LATENCY', + defaultValue: true, +); + +const testIsAndroidMediaPlayer = bool.fromEnvironment( + 'TEST_ANDROID_MEDIAPLAYER', +); + +/// Specify supported features for a platform. +class PlatformFeatures { + static const webPlatformFeatures = PlatformFeatures( + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + hasErrorEvent: false, + ); + + static const androidPlatformFeatures = PlatformFeatures( + hasRecordingActive: false, + // ignore: avoid_redundant_argument_values + hasBytesSource: testFeatureBytesSource, + // ignore: avoid_redundant_argument_values + hasPlaybackRate: testFeaturePlaybackRate, + // ignore: avoid_redundant_argument_values + hasLowLatency: testFeatureLowLatency, + ); + + static const iosPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasBalance: false, + ); + + static const macPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + hasBalance: false, + ); + + static const linuxPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasLowLatency: false, + // MP3 duration is estimated: https://bugzilla.gnome.org/show_bug.cgi?id=726144 + // Use GstDiscoverer to get duration before playing: https://gstreamer.freedesktop.org/documentation/pbutils/gstdiscoverer.html?gi-language=c + hasMp3Duration: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + ); + + static const windowsPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + ); + + final bool hasUrlSource; + final bool hasDataUriSource; + final bool hasAssetSource; + final bool hasBytesSource; + + final bool hasPlaylistSourceType; + + final bool hasLowLatency; + final bool hasReleaseModeRelease; + final bool hasReleaseModeLoop; + final bool hasVolume; + final bool hasBalance; + final bool hasSeek; + final bool hasMp3Duration; + + final bool hasPlaybackRate; + final bool hasForceSpeaker; // Not yet tested + final bool hasDuckAudio; // Not yet tested + final bool hasRespectSilence; + final bool hasStayAwake; // Not yet tested + final bool hasRecordingActive; // Not yet tested + final bool hasPlayingRoute; // Not yet tested + + final bool hasDurationEvent; + final bool hasPlayerStateEvent; + final bool hasErrorEvent; // Not yet tested + + const PlatformFeatures({ + this.hasUrlSource = true, + this.hasDataUriSource = true, + this.hasAssetSource = true, + this.hasBytesSource = true, + this.hasPlaylistSourceType = true, + this.hasLowLatency = true, + this.hasReleaseModeRelease = true, + this.hasReleaseModeLoop = true, + this.hasMp3Duration = true, + this.hasVolume = true, + this.hasBalance = true, + this.hasSeek = true, + this.hasPlaybackRate = true, + this.hasForceSpeaker = true, + this.hasDuckAudio = true, + this.hasRespectSilence = true, + this.hasStayAwake = true, + this.hasRecordingActive = true, + this.hasPlayingRoute = true, + this.hasDurationEvent = true, + this.hasPlayerStateEvent = true, + this.hasErrorEvent = true, + }); + + factory PlatformFeatures.instance() { + return kIsWeb + ? webPlatformFeatures + : defaultTargetPlatform == TargetPlatform.android + ? androidPlatformFeatures + : defaultTargetPlatform == TargetPlatform.iOS + ? iosPlatformFeatures + : defaultTargetPlatform == TargetPlatform.macOS + ? macPlatformFeatures + : defaultTargetPlatform == TargetPlatform.linux + ? linuxPlatformFeatures + : defaultTargetPlatform == TargetPlatform.windows + ? windowsPlatformFeatures + : const PlatformFeatures(); + } +} diff --git a/packages/audioplayers/example/integration_test/source_test_data.dart b/packages/audioplayers/example/integration_test/source_test_data.dart new file mode 100644 index 000000000..eb49a8606 --- /dev/null +++ b/packages/audioplayers/example/integration_test/source_test_data.dart @@ -0,0 +1,22 @@ +/// Data of a ui test source. +abstract class SourceTestData { + Duration? duration; + + bool get isLiveStream => duration == null; + + /// Whether this source has variable bitrate + bool isVBR; + + SourceTestData({ + required this.duration, + this.isVBR = false, + }); + + @override + String toString() { + return 'SourceTestData(' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} diff --git a/packages/audioplayers/example/integration_test/test_utils.dart b/packages/audioplayers/example/integration_test/test_utils.dart new file mode 100644 index 000000000..90f9ef0e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/test_utils.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +void printWithTimeOnFailure(String message) { + printOnFailure('${DateTime.now()}: $message'); +} + +bool durationRangeMatcher( + Duration? actual, + Duration? expected, { + Duration deviation = const Duration(seconds: 1), +}) { + if (actual == null && expected == null) { + return true; + } + if (actual == null || expected == null) { + return false; + } + return actual >= (expected - deviation) && actual <= (expected + deviation); +} + +extension ExtendedWidgetTester on WidgetTester { + // Add [stackTrace] to work around https://github.com/flutter/flutter/issues/89138 + Future waitFor( + Future Function() testExpectation, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async => + _waitUntil( + (setFailureMessage) async { + try { + await pump(); + await testExpectation(); + return true; + } on TestFailure catch (e) { + setFailureMessage(e.message ?? ''); + return false; + } + }, + timeout: timeout, + pollInterval: pollInterval, + stackTrace: stackTrace, + ); + + /// Waits until the [condition] returns true + /// Will raise a complete with a [TimeoutException] if the + /// condition does not return true with the timeout period. + /// Copied from: https://github.com/jonsamwell/flutter_gherkin/blob/02a4af91d7a2512e0a4540b9b1ab13e36d5c6f37/lib/src/flutter/utils/driver_utils.dart#L86 + Future _waitUntil( + Future Function(void Function(String message) setFailureMessage) + condition, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async { + var firstFailureMsg = ''; + var lastFailureMsg = 'same as first failure'; + void setFailureMessage(String message) { + if (firstFailureMsg.isEmpty) { + firstFailureMsg = '${DateTime.now()}:\n $message'; + } else { + lastFailureMsg = '${DateTime.now()}:\n $message'; + } + } + + try { + await Future.microtask( + () async { + final completer = Completer(); + final maxAttempts = + (timeout!.inMilliseconds / pollInterval!.inMilliseconds).round(); + var attempts = 0; + + while (attempts < maxAttempts) { + final result = await condition(setFailureMessage); + if (result) { + completer.complete(); + break; + } else { + await Future.delayed(pollInterval); + } + attempts++; + } + }, + ).timeout( + timeout!, + ); + } on TimeoutException catch (e) { + throw Exception( + '''$e + +Stacktrace: +$stackTrace +First Failure: +$firstFailureMsg +Last Failure: +$lastFailureMsg''', + ); + } + } +} From 44133ffa361e559e799b4e3b0297f7046314889a Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 15:30:06 +0900 Subject: [PATCH 02/11] [audioplayers] Fix position reset and AudioContext handling for Tizen Platform-specific improvements: - Fix position not resetting to 0 after Stop() on Tizen (audio_player.cc) - Handle setAudioContext gracefully instead of throwing MissingPluginException - Add tests for unsupported platform AudioContext and race condition scenarios Changes: 1. audio_player.cc: Call Seek(0) after Stop() to match other platforms 2. audioplayers_tizen_plugin.cc: Return Success() for setAudioContext calls with log emit 3. audioplayers_test.dart: Add 2 new test cases - Set global AudioContextConfig on unsupported platforms - Race condition on play and pause with asset source Test results: All tests passed (11 passed, 4 skipped) Co-Authored-By: Claude Haiku 4.5 --- .../integration_test/audioplayers_test.dart | 47 +++++++++++++++++++ .../audioplayers/tizen/src/audio_player.cc | 1 + .../tizen/src/audioplayers_tizen_plugin.cc | 4 +- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index b8b697ff4..ad789154d 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -238,6 +238,31 @@ void main() async { }); group('Audio Context', () { + testWidgets( + 'Set global AudioContextConfig on unsupported platforms', + (WidgetTester tester) async { + final audioContext = AudioContextConfig().build(); + final globalLogFuture = AudioPlayer.global.onLog.first; + await AudioPlayer.global.setAudioContext(audioContext); + + expect( + await globalLogFuture, + contains('Setting AudioContext is not supported'), + ); + + final player = AudioPlayer(); + final logFuture = player.onLog.first; + await player.setAudioContext(audioContext); + expect( + await logFuture, + contains('Setting AudioContext is not supported'), + ); + + await player.dispose(); + }, + skip: features.hasRespectSilence, + ); + /// Android and iOS only: Play the same sound twice with a different audio /// context each. This test can be executed on a device, with either /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode @@ -372,4 +397,26 @@ void main() async { }, skip: !features.hasLowLatency, ); + + testWidgets('Race condition on play and pause (#1687) with asset source', + (WidgetTester tester) async { + final player = AudioPlayer(); + + final futurePlay = player.play(wavAsset2TestData.source); + + // Player is still in `stopped` state as it isn't playing yet. + expect(player.state, PlayerState.stopped); + expect(player.desiredState, PlayerState.playing); + + // Execute `pause` before `play` has finished. + final futurePause = player.pause(); + expect(player.desiredState, PlayerState.paused); + + await futurePlay; + await futurePause; + + expect(player.state, PlayerState.paused); + + await player.dispose(); + }); } diff --git a/packages/audioplayers/tizen/src/audio_player.cc b/packages/audioplayers/tizen/src/audio_player.cc index e9121dce5..b17545f2c 100644 --- a/packages/audioplayers/tizen/src/audio_player.cc +++ b/packages/audioplayers/tizen/src/audio_player.cc @@ -111,6 +111,7 @@ void AudioPlayer::Stop() { if (ret != PLAYER_ERROR_NONE) { throw AudioPlayerError("player_stop failed", get_error_message(ret)); } + Seek(0); } should_play_ = false; diff --git a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc index 90fb5c826..da48eecb0 100644 --- a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc +++ b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc @@ -260,7 +260,7 @@ class AudioplayersTizenPlugin : public flutter::Plugin { result->Success(); } else if (method_name == "setAudioContext") { player->OnLog("Setting AudioContext is not supported on Tizen"); - result->NotImplemented(); + result->Success(); } else if (method_name == "emitLog") { auto message = GetRequiredArg(arguments, "message"); player->OnLog(message); @@ -294,7 +294,7 @@ class AudioplayersTizenPlugin : public flutter::Plugin { audio_players_.clear(); } else if (method_name == "setAudioContext") { OnGlobalLog("Setting AudioContext is not supported on Tizen"); - result->NotImplemented(); + result->Success(); return; } else if (method_name == "emitLog") { if (arguments) { From 1e257c0a5e8d45b3dcb85d73eae93fd01845b28d Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 15:32:27 +0900 Subject: [PATCH 03/11] [audioplayers] Replace Apple HLS test stream with publicly accessible Mux stream The Apple test stream (https://ll-hls-test.cdn-apple.com/) returns HTTP 403 on most devices, preventing HLS testing. Replace with Mux's public test stream which is accessible globally. This enables #4 (positionEvent with FramePositionUpdater: m3u8) test to run when re-added. Co-Authored-By: Claude Haiku 4.5 --- .../integration_test/audioplayers_test.dart | 94 +++++++++---------- .../example/lib/tabs/sources.dart | 2 +- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index ad789154d..d62915a0c 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -238,31 +238,6 @@ void main() async { }); group('Audio Context', () { - testWidgets( - 'Set global AudioContextConfig on unsupported platforms', - (WidgetTester tester) async { - final audioContext = AudioContextConfig().build(); - final globalLogFuture = AudioPlayer.global.onLog.first; - await AudioPlayer.global.setAudioContext(audioContext); - - expect( - await globalLogFuture, - contains('Setting AudioContext is not supported'), - ); - - final player = AudioPlayer(); - final logFuture = player.onLog.first; - await player.setAudioContext(audioContext); - expect( - await logFuture, - contains('Setting AudioContext is not supported'), - ); - - await player.dispose(); - }, - skip: features.hasRespectSilence, - ); - /// Android and iOS only: Play the same sound twice with a different audio /// context each. This test can be executed on a device, with either /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode @@ -308,6 +283,31 @@ void main() async { skip: !features.hasRespectSilence || isAndroid, ); + testWidgets( + 'Set global AudioContextConfig on unsupported platforms', + (WidgetTester tester) async { + final audioContext = AudioContextConfig().build(); + final globalLogFuture = AudioPlayer.global.onLog.first; + await AudioPlayer.global.setAudioContext(audioContext); + + expect( + await globalLogFuture, + contains('Setting AudioContext is not supported'), + ); + + final player = AudioPlayer(); + final logFuture = player.onLog.first; + await player.setAudioContext(audioContext); + expect( + await logFuture, + contains('Setting AudioContext is not supported'), + ); + + await player.dispose(); + }, + skip: features.hasRespectSilence, + ); + /// Android and iOS only: Play the same sound twice with a different audio /// context each. This test can be executed on a device, with either /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode @@ -362,6 +362,28 @@ void main() async { ); }); + testWidgets('Race condition on play and pause (#1687) with asset source', + (WidgetTester tester) async { + final player = AudioPlayer(); + + final futurePlay = player.play(wavAsset2TestData.source); + + // Player is still in `stopped` state as it isn't playing yet. + expect(player.state, PlayerState.stopped); + expect(player.desiredState, PlayerState.playing); + + // Execute `pause` before `play` has finished. + final futurePause = player.pause(); + expect(player.desiredState, PlayerState.paused); + + await futurePlay; + await futurePause; + + expect(player.state, PlayerState.paused); + + await player.dispose(); + }); + group( 'Android only:', () { @@ -397,26 +419,4 @@ void main() async { }, skip: !features.hasLowLatency, ); - - testWidgets('Race condition on play and pause (#1687) with asset source', - (WidgetTester tester) async { - final player = AudioPlayer(); - - final futurePlay = player.play(wavAsset2TestData.source); - - // Player is still in `stopped` state as it isn't playing yet. - expect(player.state, PlayerState.stopped); - expect(player.desiredState, PlayerState.playing); - - // Execute `pause` before `play` has finished. - final futurePause = player.pause(); - expect(player.desiredState, PlayerState.paused); - - await futurePlay; - await futurePause; - - expect(player.state, PlayerState.paused); - - await player.dispose(); - }); } diff --git a/packages/audioplayers/example/lib/tabs/sources.dart b/packages/audioplayers/example/lib/tabs/sources.dart index 069e16ca4..041d40f20 100644 --- a/packages/audioplayers/example/lib/tabs/sources.dart +++ b/packages/audioplayers/example/lib/tabs/sources.dart @@ -23,7 +23,7 @@ final mp3Url1 = '$host/files/audio/ambient_c_motion.mp3'; final mp3Url2 = '$host/files/audio/nasa_on_a_mission.mp3'; final m3u8StreamUrl = useLocalServer ? '$host/files/live_streams/nasa_power_of_the_rovers.m3u8' - : 'https://ll-hls-test.cdn-apple.com/llhls4/ll-hls-test-04/multi.m3u8'; + : 'https://test-streams.mux.dev/x36xhzz/x3lis7z94ey1eglf.m3u8'; final mpgaStreamUrl = useLocalServer ? '$host/stream/mpeg' : 'https://timesradio.wireless.radio/stream'; From 31e9b210d8718baa9c29e269c39b96991c7657ac Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 16:42:12 +0900 Subject: [PATCH 04/11] [audioplayers] Remove integration tests skipped on Tizen Remove the 4 test cases that are always skipped when run via flutter-tizen drive, since Tizen resolves to linuxPlatformFeatures (hasBytesSource, hasRespectSilence, hasLowLatency = false): - bytes array source - Audio Context: test changing AudioContextConfigs - Audio Context: test changing AudioContextConfigs in LOW_LATENCY mode - Android only: Released wrong source on LOW_LATENCY (#1672) Adding a Tizen-specific PlatformFeatures branch to force these on was evaluated on device: bytes source aborts test registration (remote bytes download during setup), the LOW_LATENCY AudioContext test fails (Tizen does not suppress the complete event), and AudioContext is a no-op on Tizen. So the skips are correct and the tests are removed rather than enabled. Validated on TV/rpi4: all tests passed (10 passed, 0 skipped, 0 failed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration_test/audioplayers_test.dart | 148 ------------------ 1 file changed, 148 deletions(-) diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index d62915a0c..1e512adb5 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -66,20 +66,6 @@ void main() async { await player.dispose(); }); - testWidgets( - 'bytes array source', - (WidgetTester tester) async { - final player = AudioPlayer(); - - await player.play((await mp3BytesTestData()).source); - // Sources take some time to get initialized - await tester.pumpPlatform(const Duration(seconds: 8)); - await player.stop(); - await player.dispose(); - }, - skip: !features.hasBytesSource, - ); - group('AP events', () { late AudioPlayer player; @@ -238,51 +224,6 @@ void main() async { }); group('Audio Context', () { - /// Android and iOS only: Play the same sound twice with a different audio - /// context each. This test can be executed on a device, with either - /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode - /// the second sound should not be audible. - testWidgets( - 'test changing AudioContextConfigs', - (WidgetTester tester) async { - final player = AudioPlayer(); - await player.setReleaseMode(ReleaseMode.stop); - - final td = wavUrl1TestData; - - var audioContext = AudioContextConfig( - //ignore: avoid_redundant_argument_values - route: AudioContextConfigRoute.system, - //ignore: avoid_redundant_argument_values - respectSilence: false, - ).build(); - await AudioPlayer.global.setAudioContext(audioContext); - await player.setAudioContext(audioContext); - - await player.play(td.source); - await expectLater(player.onPlayerComplete.first, completes); - - audioContext = AudioContextConfig( - //ignore: avoid_redundant_argument_values - route: AudioContextConfigRoute.system, - respectSilence: true, - ).build(); - await AudioPlayer.global.setAudioContext(audioContext); - await player.setAudioContext(audioContext); - - await player.resume(); - await expectLater(player.onPlayerComplete.first, completes); - await player.dispose(); - }, - - // FIXME: Causes media error on Android API 24 (min) - // PlatformException(AndroidAudioError, MEDIA_ERROR_UNKNOWN {what:1}, - // MEDIA_ERROR_UNKNOWN {extra:-19}, null) - // FIXME: [FLAKY] Audio Source sometimes does not play the second time on - // Android Exo, despite resume event is triggered. - skip: !features.hasRespectSilence || isAndroid, - ); - testWidgets( 'Set global AudioContextConfig on unsupported platforms', (WidgetTester tester) async { @@ -307,59 +248,6 @@ void main() async { }, skip: features.hasRespectSilence, ); - - /// Android and iOS only: Play the same sound twice with a different audio - /// context each. This test can be executed on a device, with either - /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode - /// the second sound should not be audible. - testWidgets( - 'test changing AudioContextConfigs in LOW_LATENCY mode', - (WidgetTester tester) async { - final player = AudioPlayer(); - await player.setReleaseMode(ReleaseMode.stop); - player.setPlayerMode(PlayerMode.lowLatency); - - final td = wavUrl1TestData; - - var audioContext = AudioContextConfig( - //ignore: avoid_redundant_argument_values - route: AudioContextConfigRoute.system, - //ignore: avoid_redundant_argument_values - respectSilence: false, - ).build(); - await AudioPlayer.global.setAudioContext(audioContext); - await player.setAudioContext(audioContext); - - await player.play(td.source); - // Low latency mode does not emit a complete event - await tester.pumpPlatform( - (td.duration ?? Duration.zero) + const Duration(seconds: 8), - ); - expect(player.state, PlayerState.playing); - await player.stop(); - expect(player.state, PlayerState.stopped); - - audioContext = AudioContextConfig( - //ignore: avoid_redundant_argument_values - route: AudioContextConfigRoute.system, - respectSilence: true, - ).build(); - await AudioPlayer.global.setAudioContext(audioContext); - await player.setAudioContext(audioContext); - - await player.resume(); - // Low latency mode does not emit a complete event - await tester.pumpPlatform( - (td.duration ?? Duration.zero) + const Duration(seconds: 8), - ); - expect(player.state, PlayerState.playing); - await player.stop(); - expect(player.state, PlayerState.stopped); - - await player.dispose(); - }, - skip: !features.hasRespectSilence || !features.hasLowLatency, - ); }); testWidgets('Race condition on play and pause (#1687) with asset source', @@ -383,40 +271,4 @@ void main() async { await player.dispose(); }); - - group( - 'Android only:', - () { - /// The test is auditory only! - /// It will succeed even if the wrong source is played. - testWidgets('Released wrong source on LOW_LATENCY (#1672)', - (WidgetTester tester) async { - var player = AudioPlayer() - ..setPlayerMode(PlayerMode.lowLatency) - ..setReleaseMode(ReleaseMode.stop); - - await player.play(wavAsset1TestData.source); - await tester.pumpPlatform(const Duration(seconds: 1)); - await player.stop(); - - await player.play(wavAsset2TestData.source); - await tester.pumpPlatform(const Duration(seconds: 1)); - await player.stop(); - - player = AudioPlayer() - ..setPlayerMode(PlayerMode.lowLatency) - ..setReleaseMode(ReleaseMode.stop); - - // This should play the new source, not the old one: - await player.play(wavAsset1TestData.source); - await tester.pumpPlatform(const Duration(seconds: 1)); - await player.stop(); - - await player.play(wavAsset2TestData.source); - await tester.pumpPlatform(const Duration(seconds: 1)); - await player.stop(); - }); - }, - skip: !features.hasLowLatency, - ); } From 8f36277d7433279fe4c39e3790e29c38aa436adc Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 17:11:55 +0900 Subject: [PATCH 05/11] [audioplayers] Return null for duration/position when no source is prepared On Tizen, release() unprepares the player (PLAYER_STATE_IDLE), where player_get_duration() / player_get_play_position() return 0 without error. As a result getDuration / getCurrentPosition returned 0 after release, instead of null as on other platforms. Add AudioPlayer::IsSourcePrepared() (true only in READY/PLAYING/PAUSED) and return null from the getDuration / getCurrentPosition method-channel handlers when no source is prepared. stop() (ReleaseMode.stop, READY state) still returns position 0, matching other platforms. Co-Authored-By: Claude Opus 4.8 --- .../audioplayers/tizen/src/audio_player.cc | 6 ++++++ packages/audioplayers/tizen/src/audio_player.h | 4 ++++ .../tizen/src/audioplayers_tizen_plugin.cc | 18 +++++++++++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/audioplayers/tizen/src/audio_player.cc b/packages/audioplayers/tizen/src/audio_player.cc index b17545f2c..da9ed370a 100644 --- a/packages/audioplayers/tizen/src/audio_player.cc +++ b/packages/audioplayers/tizen/src/audio_player.cc @@ -260,6 +260,12 @@ int AudioPlayer::GetCurrentPosition() { return position; } +bool AudioPlayer::IsSourcePrepared() { + player_state_e state = GetPlayerState(); + return state == PLAYER_STATE_READY || state == PLAYER_STATE_PLAYING || + state == PLAYER_STATE_PAUSED; +} + bool AudioPlayer::IsPlaying() { return (GetPlayerState() == PLAYER_STATE_PLAYING); } diff --git a/packages/audioplayers/tizen/src/audio_player.h b/packages/audioplayers/tizen/src/audio_player.h index 2905077b8..e771a68a8 100644 --- a/packages/audioplayers/tizen/src/audio_player.h +++ b/packages/audioplayers/tizen/src/audio_player.h @@ -55,6 +55,10 @@ class AudioPlayer { int32_t GetCurrentPosition(); std::string GetPlayerId() const { return player_id_; } bool IsPlaying(); + // Whether a source is prepared, i.e. duration and position are meaningful. + // After release (or before a source is prepared) this is false, so the + // plugin returns null for duration/position to match other platforms. + bool IsSourcePrepared(); private: // The player state should be none before calling this function. diff --git a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc index da48eecb0..f9723bf29 100644 --- a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc +++ b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc @@ -238,7 +238,13 @@ class AudioplayersTizenPlugin : public flutter::Plugin { } else if (method_name == "getDuration") { // TODO(seungsoo47): If an exception occurs, null is sent. try { - result->Success(flutter::EncodableValue(player->GetDuration())); + // Without a prepared source (e.g. after release), duration is null, + // matching the behavior of other platforms. + if (player->IsSourcePrepared()) { + result->Success(flutter::EncodableValue(player->GetDuration())); + } else { + result->Success(flutter::EncodableValue(std::monostate())); + } } catch (const AudioPlayerError &error) { player->OnLog(error.code() + error.message()); result->Success(flutter::EncodableValue(std::monostate())); @@ -246,8 +252,14 @@ class AudioplayersTizenPlugin : public flutter::Plugin { } else if (method_name == "getCurrentPosition") { // TODO(seungsoo47): If an exception occurs, null is sent. try { - result->Success( - flutter::EncodableValue(player->GetCurrentPosition())); + // Without a prepared source (e.g. after release), position is null, + // matching the behavior of other platforms. + if (player->IsSourcePrepared()) { + result->Success( + flutter::EncodableValue(player->GetCurrentPosition())); + } else { + result->Success(flutter::EncodableValue(std::monostate())); + } } catch (const AudioPlayerError &error) { player->OnLog(error.code() + error.message()); result->Success(flutter::EncodableValue(std::monostate())); From 3f22f0096f50fac38477730c48b8b78db574ae83 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 17:12:03 +0900 Subject: [PATCH 06/11] [audioplayers] Add platform-interface integration tests from upstream v6.6.0 Consolidate Tizen-runnable cases from upstream platform_test.dart into the single audioplayers_test.dart (lib_test remains the main entry point). Platform method channel: #create and #dispose (Tizen-specific exception message), #volume, #playbackRate, #seek with millisecond precision, #ReleaseMode.loop, #ReleaseMode.release, #release. Platform event channel: #completeEvent, Listen and cancel twice, Emit platform log, Emit global platform log, Emit platform error, Emit global platform error. Source-driven cases use the asset-only list to avoid unreliable remote playback on Tizen. Excluded as unsupported on Tizen: - #balance (setBalance not implemented: MissingPluginException) - loading an invalid file (no failure event emitted: times out) Adds audioplayers_platform_interface as a direct dependency. The #ReleaseMode.release / #release cases rely on the null duration/position fix in the previous commit. Validated on TV/rpi4: all tests passed (23 passed, 0 skipped, 0 failed). Co-Authored-By: Claude Opus 4.8 --- .../integration_test/audioplayers_test.dart | 327 ++++++++++++++++++ packages/audioplayers/example/pubspec.yaml | 1 + 2 files changed, 328 insertions(+) diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index 1e512adb5..333d8cf84 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -1,10 +1,14 @@ @Timeout(Duration(minutes: 5)) library; +import 'dart:async'; + import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; import 'package:audioplayers_tizen_example/tabs/sources.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -13,6 +17,8 @@ import 'lib/lib_test_utils.dart'; import 'platform_features.dart'; import 'test_utils.dart'; +const _defaultTimeout = Duration(seconds: 30); + void main() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final features = PlatformFeatures.instance(); @@ -271,4 +277,325 @@ void main() async { await player.dispose(); }); + + // Ported from upstream platform_test.dart: low-level platform-interface + // (channel contract) regression tests. Source-driven cases use the + // asset-only list to avoid network playback, which is unreliable on Tizen. + group('Platform method channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; + + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + }); + + tearDown(() async { + await platform.dispose(playerId); + }); + + testWidgets('#create and #dispose', (tester) async { + await platform.dispose(playerId); + + try { + await platform.stop(playerId); + fail('PlatformException not thrown'); + } on PlatformException catch (e) { + // Tizen reports a plugin-specific message, unlike other platforms. + expect(e.message, 'No AudioPlayer$playerId is exist.'); + } + + // Create player again, so it can be disposed in tearDown + await platform.create(playerId); + }); + + if (features.hasVolume) { + for (final td in assetTestDataList) { + testWidgets('#volume ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final volume in [0.0, 0.5, 1.0]) { + await platform.setVolume(playerId, volume); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasPlaybackRate && !td.isLiveStream) { + testWidgets('#playbackRate ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final playbackRate in [0.5, 1.0, 2.0]) { + await platform.setPlaybackRate(playerId, playbackRate); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasSeek && !td.isLiveStream) { + testWidgets('#seek with millisecond precision ${td.source}', + (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + final eventStream = platform.getEventStream(playerId); + final seekCompleter = Completer(); + final onSeekSub = eventStream + .where((event) => event.eventType == AudioEventType.seekComplete) + .listen( + (_) => seekCompleter.complete(), + onError: seekCompleter.completeError, + ); + await platform.seek(playerId, const Duration(milliseconds: 22)); + await seekCompleter.future.timeout(_defaultTimeout); + await onSeekSub.cancel(); + final positionMs = await platform.getCurrentPosition(playerId); + expect( + positionMs != null ? Duration(milliseconds: positionMs) : null, + (Duration? actual) => durationRangeMatcher( + actual, + const Duration(milliseconds: 22), + deviation: const Duration(milliseconds: 1), + ), + ); + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasReleaseModeLoop && + !td.isLiveStream && + td.duration! < const Duration(seconds: 2)) { + testWidgets('#ReleaseMode.loop ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.loop); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 3)); + await platform.stop(playerId); + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasReleaseModeRelease && !td.isLiveStream) { + testWidgets('#ReleaseMode.release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.release); + await platform.resume(playerId); + if (td.duration! < const Duration(seconds: 2)) { + await tester.pumpAndSettle(const Duration(seconds: 3)); + } else { + await tester.pumpAndSettle(const Duration(seconds: 1)); + await platform.stop(playerId); + } + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); + }); + } + } + + for (final td in assetTestDataList) { + testWidgets('#release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await tester.pump(const Duration(seconds: 1)); + await platform.release(playerId); + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); + }); + } + }); + + group('Platform event channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; + + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + }); + + tearDown(() async { + await platform.dispose(playerId); + }); + + for (final td in assetTestDataList) { + if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { + testWidgets('#completeEvent ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + expect( + platform.getEventStream(playerId).map((event) => event.eventType), + emitsThrough(AudioEventType.complete), + ); + + await platform.resume(playerId); + await tester.pumpAndSettle(const Duration(seconds: 3)); + }); + } + } + + testWidgets('Listen and cancel twice', (tester) async { + final eventStream = platform.getEventStream(playerId); + for (var i = 0; i < 2; i++) { + final eventSub = eventStream.listen(null); + await eventSub.cancel(); + } + }); + + testWidgets('Emit platform log', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'SomeLog', + ), + ), + ); + await platform.emitLog(playerId, 'SomeLog'); + }); + + testWidgets('Emit global platform log', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'SomeGlobalLog', + ), + ), + ); + + await global.emitGlobalLog('SomeGlobalLog'); + }); + + testWidgets('Emit platform error', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeErrorMessage', + ), + ), + ), + ); + + await platform.emitError( + playerId, + 'SomeErrorCode', + 'SomeErrorMessage', + ); + }); + + testWidgets('Emit global platform error', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeGlobalErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeGlobalErrorMessage', + ), + ), + ), + ); + + await global.emitGlobalError( + 'SomeGlobalErrorCode', + 'SomeGlobalErrorMessage', + ); + }); + }); +} + +extension on WidgetTester { + Future prepareSource({ + required String playerId, + required AudioplayersPlatformInterface platform, + required LibSourceTestData testData, + }) async { + final eventStream = platform.getEventStream(playerId); + final preparedFuture = eventStream + .firstWhere( + (event) => + event.eventType == AudioEventType.prepared && + (event.isPrepared ?? false), + ) + .timeout(_defaultTimeout); + + Future setSource(Source source) async { + if (source is UrlSource) { + return platform.setSourceUrl(playerId, source.url); + } else if (source is AssetSource) { + final cachePath = await AudioCache.instance.loadPath(source.path); + return platform.setSourceUrl(playerId, cachePath, isLocal: true); + } else if (source is BytesSource) { + return platform.setSourceBytes(playerId, source.bytes); + } else { + throw 'Unknown source type: ${source.runtimeType}'; + } + } + + final setSourceFuture = setSource(testData.source); + + await Future.wait([setSourceFuture, preparedFuture]); + } } diff --git a/packages/audioplayers/example/pubspec.yaml b/packages/audioplayers/example/pubspec.yaml index 0df215b80..ba5b63cc4 100644 --- a/packages/audioplayers/example/pubspec.yaml +++ b/packages/audioplayers/example/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: audioplayers: ^6.6.0 + audioplayers_platform_interface: ^7.1.1 audioplayers_tizen: path: ../ collection: ^1.16.0 From d10b1e7c91f856518912eb780d78ad68826d8d18 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Tue, 16 Jun 2026 18:30:48 +0900 Subject: [PATCH 07/11] [audioplayers] Fix crash on stop and non-platform-thread event emission Two issues observed on TV with the example app: 1. Crash: stopping a (network) source threw PlatformException(player_set_play_position failed, Invalid state) from Stop() -> Seek(0). The position reset after stop is best-effort, so wrap it in try/catch and log instead of letting it propagate and crash the app. 2. Threading: OnError and OnInterrupted ran on the player's callback thread (not the main loop on TV) and emitted log events directly, triggering "channel sent a message from native to Flutter on a non-platform thread". Marshal both to the main loop via ecore_main_loop_thread_safe_call_async, like the other player callbacks. OnError carries its message through a heap-allocated context. Validated on TV and rpi4: integration tests still pass (23 passed, 0 skipped, 0 failed). The fixed paths (network-source stop, player error/interrupt) are exercised by the example app rather than the asset-based integration tests. Co-Authored-By: Claude Opus 4.8 --- .../audioplayers/tizen/src/audio_player.cc | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/audioplayers/tizen/src/audio_player.cc b/packages/audioplayers/tizen/src/audio_player.cc index da9ed370a..4908a5965 100644 --- a/packages/audioplayers/tizen/src/audio_player.cc +++ b/packages/audioplayers/tizen/src/audio_player.cc @@ -4,6 +4,9 @@ #include "audio_player.h" +#include +#include + #include "audio_player_error.h" #include "log.h" @@ -111,7 +114,15 @@ void AudioPlayer::Stop() { if (ret != PLAYER_ERROR_NONE) { throw AudioPlayerError("player_stop failed", get_error_message(ret)); } - Seek(0); + // Reset the play position to 0 to match other platforms. This is + // best-effort: on some devices (e.g. TV with network sources) + // player_set_play_position right after stop can fail with an invalid + // state, which must not crash the app. + try { + Seek(0); + } catch (const AudioPlayerError &error) { + OnLog("Failed to reset position on stop: " + error.message()); + } } should_play_ = false; @@ -460,17 +471,31 @@ void AudioPlayer::OnPlayCompleted(void *data) { } void AudioPlayer::OnInterrupted(player_interrupted_code_e code, void *data) { - // On TV devices, callbacks are not executed on the main loop. - // However, race condition will not occur as player_id_ is read-only. - const auto *player = reinterpret_cast(data); - player->log_listener_(player->player_id_, "Player interrupted."); + // On TV devices, callbacks are not executed on the main loop. Transfer to + // the main loop so the log event is sent on the platform thread. + ecore_main_loop_thread_safe_call_async( + [](void *data) { + auto *player = reinterpret_cast(data); + player->log_listener_(player->player_id_, "Player interrupted."); + }, + data); } void AudioPlayer::OnError(int code, void *data) { - // On TV devices, callbacks are not executed on the main loop. - // However, race condition will not occur as player_id_ is read-only. - const auto *player = reinterpret_cast(data); - player->log_listener_(player->player_id_, get_error_message(code)); + // On TV devices, callbacks are not executed on the main loop. Transfer to + // the main loop so the log event is sent on the platform thread. The error + // message is resolved here and carried via a heap-allocated context. + auto *context = new std::pair( + reinterpret_cast(data), get_error_message(code)); + ecore_main_loop_thread_safe_call_async( + [](void *data) { + auto *context = + reinterpret_cast *>(data); + AudioPlayer *player = context->first; + player->log_listener_(player->player_id_, context->second); + delete context; + }, + context); } void AudioPlayer::StartPositionUpdates() { From a28154cb3daf9753f2014fbb21dc160b190aaeb6 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Wed, 17 Jun 2026 09:33:58 +0900 Subject: [PATCH 08/11] [audioplayers] Declare integration tests synchronously to fix TV emulator CI The integration test failed on the tv-9.0 emulator with: Bad state: Can't call test() once tests have begun running. main() awaited getAudioTestDataList() before declaring any testWidgets(), yielding to the event loop. On the slower TV emulator the test runner started before the post-await declarations ran, so testWidgets() was rejected. Real devices (TV 10.0, RPI4) happened to win the race and passed. Only local asset sources are used anyway (remote playback is unreliable on Tizen), so build the asset list synchronously and drop the await. main() is now synchronous and all tests are declared before the runner starts. This also removes the now-unused remote/bytes path from getAudioTestDataList(). Validated on TV and RPI4: all tests pass (23 passed, 0 skipped, 0 failed). Co-Authored-By: Claude Opus 4.8 --- .../integration_test/audioplayers_test.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index 333d8cf84..b50e8e31c 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -19,16 +19,16 @@ import 'test_utils.dart'; const _defaultTimeout = Duration(seconds: 30); -void main() async { +void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final features = PlatformFeatures.instance(); final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; - final audioTestDataList = await getAudioTestDataList(); - // Tizen: remote URL/stream playback does not emit reliable complete/position - // events on the device, so data-driven tests are restricted to local asset - // sources. Network-based test cases were removed after on-device validation. - final assetTestDataList = - audioTestDataList.where((td) => td.source is AssetSource).toList(); + // Only local asset sources are used; remote URL/stream/bytes playback does + // not emit reliable events on Tizen. This list is built synchronously (no + // await) so all tests are declared before the test runner starts. Awaiting + // getAudioTestDataList() here races the runner on the TV emulator and fails + // with "Can't call test() once tests have begun running". + final assetTestDataList = [wavAsset2TestData]; testWidgets('test asset source with special char', (WidgetTester tester) async { From 435e7436e69f29c439b64c0c7a7f40e2f8f04409 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Wed, 17 Jun 2026 11:47:36 +0900 Subject: [PATCH 09/11] [audioplayers] Bump audioplayers_tizen to 3.1.5 --- packages/audioplayers/CHANGELOG.md | 8 ++++++++ packages/audioplayers/README.md | 2 +- packages/audioplayers/pubspec.yaml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/audioplayers/CHANGELOG.md b/packages/audioplayers/CHANGELOG.md index 93447220c..8f0d74940 100644 --- a/packages/audioplayers/CHANGELOG.md +++ b/packages/audioplayers/CHANGELOG.md @@ -1,3 +1,11 @@ +## 3.1.5 + +* Reset the play position to 0 after `stop` (best-effort) to match other platforms. +* Handle `setAudioContext` gracefully instead of throwing on the global channel. +* Return `null` for current position and duration after `release`, matching other platforms. +* Fix a crash when stopping a network source. +* Emit player error and interrupt events on the platform thread. + ## 3.1.4 * Remove Ecore API. diff --git a/packages/audioplayers/README.md b/packages/audioplayers/README.md index ce79ca6b1..0463ee346 100644 --- a/packages/audioplayers/README.md +++ b/packages/audioplayers/README.md @@ -11,7 +11,7 @@ This package is not an _endorsed_ implementation of `audioplayers`. Therefore, y ```yaml dependencies: audioplayers: ^6.6.0 - audioplayers_tizen: ^3.1.4 + audioplayers_tizen: ^3.1.5 ``` diff --git a/packages/audioplayers/pubspec.yaml b/packages/audioplayers/pubspec.yaml index 19982e7b6..11645686b 100644 --- a/packages/audioplayers/pubspec.yaml +++ b/packages/audioplayers/pubspec.yaml @@ -2,7 +2,7 @@ name: audioplayers_tizen description: Tizen implementation of the audioplayers plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/audioplayers -version: 3.1.4 +version: 3.1.5 environment: sdk: ^3.6.0 From 991ad98d2bce95b840484cfa79297d878dd3ecf6 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 18 Jun 2026 20:08:25 +0900 Subject: [PATCH 10/11] [audioplayers] Replace ecore thread dispatch with g_idle_add_full ecore_main_loop_thread_safe_call_async was removed in the Ecore API cleanup (#1033). Replace the two remaining usages in OnInterrupted and OnError with g_idle_add_full, consistent with OnPrepared, OnSeekCompleted, and OnPlayCompleted. Co-Authored-By: Claude Sonnet 4.6 --- .../audioplayers/tizen/src/audio_player.cc | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/audioplayers/tizen/src/audio_player.cc b/packages/audioplayers/tizen/src/audio_player.cc index 4908a5965..e95be6ca6 100644 --- a/packages/audioplayers/tizen/src/audio_player.cc +++ b/packages/audioplayers/tizen/src/audio_player.cc @@ -471,31 +471,47 @@ void AudioPlayer::OnPlayCompleted(void *data) { } void AudioPlayer::OnInterrupted(player_interrupted_code_e code, void *data) { + auto *self = reinterpret_cast(data); // On TV devices, callbacks are not executed on the main loop. Transfer to // the main loop so the log event is sent on the platform thread. - ecore_main_loop_thread_safe_call_async( - [](void *data) { - auto *player = reinterpret_cast(data); - player->log_listener_(player->player_id_, "Player interrupted."); + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + [](gpointer data) -> gboolean { + auto *idle = static_cast(data); + if (!*idle->is_alive) { + return G_SOURCE_REMOVE; + } + idle->player->log_listener_(idle->player->player_id_, + "Player interrupted."); + return G_SOURCE_REMOVE; }, - data); + new IdleData{self, self->is_alive_}, + [](gpointer data) { delete static_cast(data); }); } void AudioPlayer::OnError(int code, void *data) { + auto *self = reinterpret_cast(data); // On TV devices, callbacks are not executed on the main loop. Transfer to // the main loop so the log event is sent on the platform thread. The error // message is resolved here and carried via a heap-allocated context. - auto *context = new std::pair( - reinterpret_cast(data), get_error_message(code)); - ecore_main_loop_thread_safe_call_async( - [](void *data) { - auto *context = - reinterpret_cast *>(data); - AudioPlayer *player = context->first; - player->log_listener_(player->player_id_, context->second); - delete context; + struct ErrorData { + AudioPlayer *player; + std::shared_ptr is_alive; + std::string message; + }; + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + [](gpointer data) -> gboolean { + auto *error_data = static_cast(data); + if (!*error_data->is_alive) { + return G_SOURCE_REMOVE; + } + error_data->player->log_listener_(error_data->player->player_id_, + error_data->message); + return G_SOURCE_REMOVE; }, - context); + new ErrorData{self, self->is_alive_, get_error_message(code)}, + [](gpointer data) { delete static_cast(data); }); } void AudioPlayer::StartPositionUpdates() { From 0ebdeb34352c4747d71ee1fe22445fe435412ede Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 22 Jun 2026 11:19:48 +0900 Subject: [PATCH 11/11] [audioplayers] Address review feedback - Remove comment on IsSourcePrepared() per review request - Remove non-Tizen platform feature definitions from platform_features.dart; Tizen always uses the default PlatformFeatures (all features enabled) Co-Authored-By: Claude Sonnet 4.6 --- .../integration_test/platform_features.dart | 120 +----------------- .../audioplayers/tizen/src/audio_player.h | 3 - 2 files changed, 7 insertions(+), 116 deletions(-) diff --git a/packages/audioplayers/example/integration_test/platform_features.dart b/packages/audioplayers/example/integration_test/platform_features.dart index 75d754001..f80b24657 100644 --- a/packages/audioplayers/example/integration_test/platform_features.dart +++ b/packages/audioplayers/example/integration_test/platform_features.dart @@ -1,97 +1,5 @@ -import 'package:flutter/foundation.dart'; - -const testFeatureBytesSource = bool.fromEnvironment( - 'TEST_FEATURE_BYTES_SOURCE', - defaultValue: true, -); - -const testFeaturePlaybackRate = bool.fromEnvironment( - 'TEST_FEATURE_PLAYBACK_RATE', - defaultValue: true, -); - -const testFeatureLowLatency = bool.fromEnvironment( - 'TEST_FEATURE_LOW_LATENCY', - defaultValue: true, -); - -const testIsAndroidMediaPlayer = bool.fromEnvironment( - 'TEST_ANDROID_MEDIAPLAYER', -); - /// Specify supported features for a platform. class PlatformFeatures { - static const webPlatformFeatures = PlatformFeatures( - hasPlaylistSourceType: false, - hasLowLatency: false, - hasForceSpeaker: false, - hasDuckAudio: false, - hasRespectSilence: false, - hasStayAwake: false, - hasRecordingActive: false, - hasPlayingRoute: false, - hasErrorEvent: false, - ); - - static const androidPlatformFeatures = PlatformFeatures( - hasRecordingActive: false, - // ignore: avoid_redundant_argument_values - hasBytesSource: testFeatureBytesSource, - // ignore: avoid_redundant_argument_values - hasPlaybackRate: testFeaturePlaybackRate, - // ignore: avoid_redundant_argument_values - hasLowLatency: testFeatureLowLatency, - ); - - static const iosPlatformFeatures = PlatformFeatures( - hasDataUriSource: false, - hasBytesSource: false, - hasPlaylistSourceType: false, - hasLowLatency: false, - hasBalance: false, - ); - - static const macPlatformFeatures = PlatformFeatures( - hasDataUriSource: false, - hasBytesSource: false, - hasPlaylistSourceType: false, - hasLowLatency: false, - hasForceSpeaker: false, - hasDuckAudio: false, - hasRespectSilence: false, - hasStayAwake: false, - hasRecordingActive: false, - hasPlayingRoute: false, - hasBalance: false, - ); - - static const linuxPlatformFeatures = PlatformFeatures( - hasDataUriSource: false, - hasBytesSource: false, - hasLowLatency: false, - // MP3 duration is estimated: https://bugzilla.gnome.org/show_bug.cgi?id=726144 - // Use GstDiscoverer to get duration before playing: https://gstreamer.freedesktop.org/documentation/pbutils/gstdiscoverer.html?gi-language=c - hasMp3Duration: false, - hasForceSpeaker: false, - hasDuckAudio: false, - hasRespectSilence: false, - hasStayAwake: false, - hasRecordingActive: false, - hasPlayingRoute: false, - ); - - static const windowsPlatformFeatures = PlatformFeatures( - hasDataUriSource: false, - hasPlaylistSourceType: false, - hasLowLatency: false, - hasForceSpeaker: false, - hasDuckAudio: false, - hasRespectSilence: false, - hasStayAwake: false, - hasRecordingActive: false, - hasPlayingRoute: false, - ); - final bool hasUrlSource; final bool hasDataUriSource; final bool hasAssetSource; @@ -108,16 +16,16 @@ class PlatformFeatures { final bool hasMp3Duration; final bool hasPlaybackRate; - final bool hasForceSpeaker; // Not yet tested - final bool hasDuckAudio; // Not yet tested + final bool hasForceSpeaker; + final bool hasDuckAudio; final bool hasRespectSilence; - final bool hasStayAwake; // Not yet tested - final bool hasRecordingActive; // Not yet tested - final bool hasPlayingRoute; // Not yet tested + final bool hasStayAwake; + final bool hasRecordingActive; + final bool hasPlayingRoute; final bool hasDurationEvent; final bool hasPlayerStateEvent; - final bool hasErrorEvent; // Not yet tested + final bool hasErrorEvent; const PlatformFeatures({ this.hasUrlSource = true, @@ -144,19 +52,5 @@ class PlatformFeatures { this.hasErrorEvent = true, }); - factory PlatformFeatures.instance() { - return kIsWeb - ? webPlatformFeatures - : defaultTargetPlatform == TargetPlatform.android - ? androidPlatformFeatures - : defaultTargetPlatform == TargetPlatform.iOS - ? iosPlatformFeatures - : defaultTargetPlatform == TargetPlatform.macOS - ? macPlatformFeatures - : defaultTargetPlatform == TargetPlatform.linux - ? linuxPlatformFeatures - : defaultTargetPlatform == TargetPlatform.windows - ? windowsPlatformFeatures - : const PlatformFeatures(); - } + factory PlatformFeatures.instance() => const PlatformFeatures(); } diff --git a/packages/audioplayers/tizen/src/audio_player.h b/packages/audioplayers/tizen/src/audio_player.h index e771a68a8..9d634bede 100644 --- a/packages/audioplayers/tizen/src/audio_player.h +++ b/packages/audioplayers/tizen/src/audio_player.h @@ -55,9 +55,6 @@ class AudioPlayer { int32_t GetCurrentPosition(); std::string GetPlayerId() const { return player_id_; } bool IsPlaying(); - // Whether a source is prepared, i.e. duration and position are meaningful. - // After release (or before a source is prepared) this is false, so the - // plugin returns null for duration/position to match other platforms. bool IsSourcePrepared(); private: