From 39896a5c2489ae1aa7366ffdb01d2c3dfcb6e1f0 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Thu, 10 Sep 2026 08:28:07 +0800 Subject: [PATCH] fixed some bugs --- ios/OralTrainer.xcodeproj/project.pbxproj | 4 + .../UserInterfaceState.xcuserstate | Bin 23197 -> 23517 bytes ios/OralTrainer/Player/PlayerController.swift | 95 ++++++++- .../Services/StreamingVideoCache.swift | 188 ++++++++++++++++++ ios/OralTrainer/Views/CatalogView.swift | 17 +- 5 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 ios/OralTrainer/Services/StreamingVideoCache.swift diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index d07f68b..33d9a50 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; }; AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; }; + AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A6 /* StreamingVideoCache.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -66,6 +67,7 @@ AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = ""; }; AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = ""; }; + AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -132,6 +134,7 @@ AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, + AA00000000000000000000A6 /* StreamingVideoCache.swift */, ); path = Services; sourceTree = ""; @@ -253,6 +256,7 @@ AA000000000000000000004B /* ImitationAssessor.swift in Sources */, AA000000000000000000007D /* DubbingCompositor.swift in Sources */, AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */, + AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 2043b23e2f6f19632c7c3ca80bfcb14308f71ae4..80f746ccc44ed575f39d494441b2bb1f92d3db24 100644 GIT binary patch delta 10619 zcma)B2UwHW*Ux#!1q2~sg%Cm@2@oKGkN`=@1_G$y0<~^YP-#&Va8}KIopq16wJO8i zS`}BT)>XGvi`v>+JMFU8YOQtE`rQQa(|`ZZ_k8fY@0)w>{+-`B=Z?2`!}YzepcL*e z9N6G$1HKRl!4Lvch=Bx9Lkegi6ZBvJGg!a@*-!xOpa{A^SLgw~p+8i?2p9>~Py@9v z3P!^i;9xRLfvNBotb{t)1nNhH9kNQ*F0UyQtmN9_lc4ggWZxQ#Mx?ZKZ8=4xLLE(Cz3Vx+C3{E~b0XuhD(z zzH}))kRD7Aq2HjZ=o$1|^h|mdJ)53G&!y+l^XUcjLV5|kntq#JLvNrr(wpe*^bUF_ zeSkhle@GvqkI^UTQ}k*26S|rHhW?iRj{cs$LH|JiNZ+J?rGKaI(T`m8WBLh0GYrEr zi1A?p7(Sz95}70>nNcxnCWT35G)x+!WilBH<7D!fe5MQ2l__RQnAey-Oc_(o3}c2f z)yynr9<8?J>{0d%dzL-NUS_Yb-?KN^AJ|{m+wAY`1NI^Nh<%Cx`5<59 zhuWeLBt)@DhU7@$LP;nYr6MgdAS1FM2kL^lqGD8nx}ol<2kM3Tq5)_S8jLEC3t_~e z$!H3iil(9IXa;%<%|x@%Y%~YWMJv!svmRLWj{2 zbR3;VpQA6(HS{IAj=n6my4?p8W<5dp^ec{fAGV z`S{}J-~DjAr5-@bmnV7tZT&pB#B0k7dx?uIMy zJNO*_7=MaY?lHk}?t_A4_sU?o`+=Z?JfvN&Gq-$j>8Q%u-132=hB&IrOZyd-j;$D0 zF}}R4xVE&m+-(omx4i@`AYKso5I^MS7RnR4^MTL9U7C3lcoV@N0(y5WZs!51Gl8of z+Vb$rg6D&TAW}!QZXH(fd7nZkgh4oIQV0#tr%QUUfs<*4%`!W#l47X zq;x81+$YJm9@5;$h4S*2YC1><74C!k;$Gc5SC)<|ujW&&O&UE-;{Ki%dUvWWtt_c7 ztr%Wj-I{InW|#guJBJJ>7o3m>`R<|!Av*|{VV+wosbru%bZ`&XhO_lh$itpr;$~8~ zBXok!y*v~vuC1;ZJ_HZJgZuN_x%mn0+`~mEv=~Zy=XNj2tEepZ0NViFpu77mQ9S(` z^mOkKsiBwqYmqL$5AS*%^u@2^oK~an*YNx9EO zHh4*?t;JqaC!kAt7g4``*qLwLdquZKtQ7%#y~>)~&BikIP4q}9VFi90eSsXs+gEGa_K6oZ%J z74;P2UBoM~9mn-6s~R+F7_q9t^5H{jhxV={Cg3%#@*2UAHP3sX+EM~ucmowk1yOvw z8o!O#G*H1*2o;Lg;yPUKcEw;tu zUHLDYW@lG_kJov<2^CFAyAyD`wacxiqItNPiY4`B|E`}DEWf|Et9^ib?wh{l7rwiP z*U9Z=>%5*(Zcl6Rko?$7Jge73Qc5b>GtopU32(p~>vl|$v?9e58ugb!oYYlY>5Yr9vJkLlU9V#rYMuU>_vHY@J`%>cj4WQgh+RI7YTvd5{KC-WMWEdh2B&d zPqm5aL-nQlQT?d_R4Fx(8ie=aefWL+0p5=f;Dh+XrW>IvK-gMm3^k6Ys-wo@BX!hx zeAL}R7L_rHcfFo+Q5YY?r(gIOY6>;Oqxz}TG-^8j2p`8M8mPB;@zgAQk`VWlJ2SLh z47G?_`maLENTC(@3@LQhy*4yFg4f#jTB^>|_d0yOj;hBOynS!*^u3YVgfHSt|J(Og z>b-w8yPY(<6MsUQZFaAgNu+zJ{r@U+fRySeMO%m?Z_B~Ep4T=G zwsUZrgR30;*)&a21&ZY~Mbk7xT-LAnH~hQD@4WB}v>)c-$@rGLF6oWnm&Acy@oo1r zW&fAOAztwv_lU$2cdrBmdV?cAR^@(2qH*s^80$*mskYDxI*yK~6KEwYr<3SpT1Bhz zU-%(@jQ_^}V9WuJ1BwHN1GI%sr8RUKt)+EzI-Nmh(t6s!fiDMs9JJvekpl|{Rt{d{ zpeF|v9K4QIFOS~h9nXXQ{7z%l6~o=}$;!-(^z6LMoLqHIrpBR8Ps_+t8#K-|b)F;7 z>CDr3q_50B+uOn;w=_A$eM%Yk@?`V9Sx^2GgF>(PsRx6XOLy^#pLrXqNQw)23C!-E zEDpe%$Gn=?yCskIDB$Hf16t%PsZ;#2yu3y3<87-cS!EtdSF|iio^z0{q=&sK7(u`3 z(aT7>ny#U1=~47(dJH|59!HON2U)^0I0)t-goDs6^n@0lPGg#*C-W5aR9-v>VT5bM zsdFIWAdUlKq#PtPJ=2yzH+m6q=(L-I@aG<#UP?SVy^LP&c{mVq5Yh6+4*oR0sC9^I z>3ZVH=yh}*2az0z>*)r%k%K4>q%S;svS>5?j>q3I^j5rr0|{|>^fqkgAllt8B}&mm z?es*N*;Lgw=d)+L34sYN<`BJxOnGsp^CHixoYCV0KzCwRWe@0*BAc=!y z4pbbdIY?Pge?ecPzof6zUvZGifrf)L4zwKTn(m~w1^OrY=S67=j$8C^ElTnRLi&ye z$GaS)W5;FsPqG9fv7Ek7|3yEbANH&%uPh%_TTwNf13d?s91xY63H^%Q`_lyCzv*W@ zRXzQb{)Ynt2gZ7a$B=z;76&HxPiba`@n!tn9knrR1Jj1_cNc2y$!(dyR~W(wm?**! zCYT9fLYXiooDnh+jEIS3#O}k|FdYYZ9OQFQu!WJdFocP1iTX^Ohav4+8PcI8?sL$c z@S_{F*O6&4=^V6wP7g-^UjzPgE;iVBX)I*~lFN(R-GDXQ%%!pStP{U06 zKWx`-Rw{0t<}zlbM;Xf*GD$%{4*J(KtC-as4B%kU3nNX6Wa^m47C0I_ zaFh~rVK#WIbfEi9eN^gJ#uMiL(@L4`%npyr$~Y*;^*FAX*-f;thZyAvy~jqG_et^t zlDw;rO(r&~YO&GQp?%04X@T~z2il=8bxE*won%hEitSnE+^d2M3^C!&op!@LfgZ+tm0Ft=Lrs=RrlU(LJQk~h+u_oY$g`jff; z>NFlQk6so0%{+ZY!17plRlu<9s{&uv?^Qtn+xArfpA~ooF4c4R*-$p@)glqB)U#Jx zPEbsy6PRX#B=&+w;lI6jj@ZD)u(7O+m9q+##E=Ob%;R7_2WvUl#KC5QSj$3`RkFJO zpB=F2Eq1^fSRRaj^J> zV|n|3@m%S_a}@`z8#;E42hX+aI?uzwA`Zyf!1E?}D*0_&XSk8w;=ywhyIH`&5)PKu zvs>ABI9SHP$`(9#u)F^oo_pB69z2Q9TG4{%egfbD0-(a`#q$tJ9wy07*4UUAcphU< zczXMYBBs2GY!KO#yo(&Hb}z6-i#}#AwxD^zgXS6!nkPM2uO?nPB8&Z${qp~e_3U-} z8v8YUo&A;rGG7ww>%6hP-fgm(N`Ca9^AiURF9mh>Ci|-=sB^HMgT|InE+?maL;t+L z^L8sfci4NchIIB%_C5z>gd5#wY#QBT_Q|VC{)Z)N0MD`1^A-?=7+!cCqB+=Fhgc5Y zar@h22ckA)L_7heUTpkOAR-4^URfO?%6RX2$UvdwK*n3L^*{zipeUjeBtnr$%)t&0 zc5=|vfFvjyNjccX!9ETSxaZmDxZ+R(X%i9l?5;yf4)$Qx%ZE_SNW}|6YLwE0Y^Q50 zhLw}~E9W2F{FH#Q*Smp68k9yh(7EL7t{!Q4xOG-Kl;LSOorCx55LpL(@E;A6X0wnf z2$_45mTSD{Q5@`dZ*tgOR%GkX59(N4G^)0AU}bsBVO0aNB0CSi*0H!H-P%t!YDfGT z%0aoviSkfBD&XKC2On~9h=ao%9O2;T2GkyPK!vCXbwtD~9OK|44vuqhf`gMBoWiR9 z{6r1;Go)!XYE9FS>?m+~=KUHN$!+ucKOC_-6D5 zszk%ka8!jxppmE=kyv(ygR>l*QMtCHbIPuO!(G|y)xQ}wvrP)vHkicwa5jRCWTV)0az#_at|nJ5&yW~pB0cI!?n#CaQcOUT z$Q{XKaz8Si+>Oj4_abXi9Z~0cqP)#$tBa`WJ#s11M1cDo{pQ2-3H3?vN%zV3>Fe{l zPmRwgpD{k;eBSh#>NDNvEuUFFbA0Cc?C?46^O?_6-!{G>zG1#XUy-laSK%A)tMpCs zRr#j)I(>WkR{PHNUFo~ZceU>t-;KUIefRsG^8MKNQ{TJ3|M-C)?T7rx6^LJ)pDV?$ zgI|$fr#55S;5M7vY;W_qztunAzpsCp|1AGy{;U1h_^U2;LTaIrvrx6(R_U3n>Wc7E%#1A!JgB3x`Y&nH{n$ zWOc}a>!31e}x8y#)eu#ZK00PoKR!cM}j!V+P3;UM7%VYRSUI9fPXI9@nGI8!)VI9E7dxKQX8E)gyh?iC&r zo)>;8yc0o1ghqr%$Rm;>R1ql=nh1S_F~SsKiLgbKL{vshiI@{HKVo5o%N?;V;&Q}8 zQ5%s`WEFK4^%G4HO%h?zWYJX7bkSR)S)w_jd7_P?&7!TMcSY}sc8Hop2Smq3Cq<`4 zXGI^2E{MJqeI@!v^quH;(OuC!(fvp&l8Ho-zL8;(!bnl1I8qsz6sd|#i8Mv#M0Seo z5?LJCE%HF*@yN507bAakiNnQmu|b?AHjAxdyEt2%E6x)ah}(+`#iimw;&Sm2afSE| z@i1|fc%-;SJW4!9yja{M{!IKRN)nY5H6&_!)cUBSQD>vhM_r6+j=C0gBkISfpQCO@ z-HN&$btmeNsAm#Jf+W6@wvr%;KoTO+NQxx`B;}GJk_yRiiED(UT2d>SBAG8)Bv~uj zAlW3@B6&yBB-t(5D|uhCUveWlJX#-Zk1mQH5Ir#Zjp*v=G0`}BZuGk7`sl6EyQ23* zAB#Q}eJ1)`^e54mqOU}M7JWVXR`f%uRH~HPq`A`G(lTkebcnQ8I#KG9a?&Z%Y0`Pp z1=2;*#nPqHCe)e(p%E|(g)H<(kIfV(q}P}n5>w-F{5Lq z#4L_k8nZlRWz6cBH8JaA>SG#XcE{|Ec|T@<%)yvLF-KyK#T<`08FN22F1Bkd7yC}^ zm$E>aMb=(cBT{**nDJ(WF^gWOl%Mjjv!l=J10a=AQCu9auV_3|vaS#FhgmG_npkPnoX z$p_18{G|M}{H*-C{73l{ zg|EWjrD&_*D}oiF3a!GX$W`Pi3KT_(PKqvyV#OPZxr&vF-HN@6_Z9mU2Nj1DM-;~t z#}y|Prxj-vA1f{>ZYpjmZY%C8?kVmo9x5Iyp2qRw=r|PT7v~?>HclDmjO!XVF0L-F zDeiRKk8zLV1@XD@-Q$PFkB)c6bMaH+r^UNw#LtXh8oxY#W&G*{ln{~-mLNuCVuTo zyp?!6@lN6&Nu87WCiPD$O&XN6C+S$y@uZVUr<29W%H*VERdQ-Fmpm_dLGq&HCCSfJ z0jfY1UlpP%S5>QORijnoRA*IJRiCTAP<^TTMRim4tLit^V|9=^SRJO0P>a>k>R7cx zouE!qtJT?Rr#fHVPTfJ>QQbvdqIPvx_f+>*_f?NquTURUf1N_7B&HOlRHn>M*_5&) zWmn4Hl=oAPrkqVVpYln{<&@7-K2N!pay{i{%I_(Eq})$=oboi4mrA8dQ?pY$rIw_2 zPwko7FLglbpw#lz+SDYCKL)P~gcspmC3O{^wKqt~?86uLCs zGy^rGG-EVaGg&iVvsklKvqG~*vrf~XS+CipIifkIxu9v*T+v+BT+{rh`9*U}^SkB` z&3(;-w1l*hv?*yj(!NQ1to70QYW=kST7@=EtJUhX8Ct8>q0QCiX?ti(wPo5N+Sj$i zw1>5)wV!IQX}{8btNl^?i}se*bzA$F_NmTKC)cTTg}P#0jc%rHscxBWxo)Lyqwaw2 zknX7NxbBqhtnR$-6WwLqXSy$Rzw7?c{i*v)_fYpl_f+>R9nzU}lN=rnHj|yeKPuI49qCY7?v?QV{FFwj0qW&GH}M+j71qsGL~n&?aEk}(U7q| z<5b2E8GmFv%J@6uStga~ml=>5lqtvz$uwsU&zzsRJac{KzRV9Yk7ZuW{66!C%s(<8 zWIoFLTMv3#&+2{j;d;4VsZY|Y^;&(pK2vYd=j%)K-Spk{uj$M6q|qyMCwMwM)N8|Gxf!{zLs?{W1M<{Ym{r{U!aU`m6eH^*8mu>F?<8 z>HpF{)IT=(7y=AIhG2ux5NVJYqz1F0zoF7F(oka1|hW&;Q4TlX!4QCBk4Zj+GGyG+wjp4=!W2`a7s55354KAa_XftLTbB)Eu0mf3} zKx3J4q_NsqV;p6iY+PboW?W%hZCq=tGd3DG8n+nVH6At|GoCP>Hl8zHFg6>n7_SvD(#-ui-m{Lt?CM(&6 z+D+M}P9|4pQx{XQX@IHJG|*IL8fmIF)tE+^CYz?1rkbXk+@{5*C8lMjM$>xJ2Gb_f zZqrfIN2ZgeGp2K<3#Mk%*QW1GKbU?t{c5^xx@)>;=9$CH5oWPD+8k?Em=nxNX0=&k z)|m^;9n3}MPUbG=5_1o8FLNJre{-pMkh#)jt}<7fYt0kQ)6Fx@bIkM2i_DA7OU=v8 z>&;ut@0z!pcboT__nQxzzcD|xw6O>*p%$Sf(h_S?SQ0FWmSju1#bGJ2bhq@kR9ajX z&N9#9wk)wMx4dmxYpJ(1TAD0-EQc&dEaxqkELSX7E!QnyTfVd0u-voqtYCFfR>mr@ z##4$Yt674tR}0~>agZods_Qg`&kEA2U^RmL#?k{E3H-5k=7dP1gpzB**eua z*SgfY!n)eJ)>?0Ev~I9&vhKEiU_EF(Y&~v0Wj$;C*m}p-))r}twJB@~wj^7cE#0QK z8Eqz8uC0r$%r@9o<+4q%&9^PIt+6%OHrO`V-nDJF?X>N(9kZRVU9w%ZePO$9`_6X5 z_LJ=w+e15R=i5W<;dYTd$}Y7h+0}N9U1!g<8|@~0H~Vn=82fnpM7ztr!0xs$wXd+R zwy(GEwC}ZlU_WR-YCmp2Wj|y8)PBeQ)c%kCnFAbw4p)$a?+A8C9WqCpL+MC%s2wIp zzN53F*wM|=!%^lK;;3*`I;tEa9pfDn9WDpwnCDpHSngQmSmRjl*yPyi*yh;o_$1pe zTbpgm&dzpb7i4$HF3RqiJuv(A?3(P+*{%#M#5y%Q?(h<*atrI>$K2J107^bBfb7%{jxl$hp~h)Op2uD-ZJg^W=HvJZIkc zJe)T-Z%JN#-qyTrc{}oU}#yRY699z96@tLqW%aE(Ik8y$kvk zlnyT_D;Qi*RWQ21RWPG)Y2k{(`oisndka4-JX?6R@LJ(lh2Iw5DEz7LX5oXv$Ay0v zJ}aV%m?Bi@XS-0PJ delta 10584 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 diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 8b9d87c..c4e1008 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -20,12 +20,18 @@ final class PlayerController: ObservableObject { private var lastSentenceIndex: Int? private var pendingSeekPositionMs: Int64? private var seekGeneration = 0 + /// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在 + /// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断 + /// 用户是否希望继续播放。 + private var playbackIntent = false private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? private var swipeBackAnchorIndex: Int? private var swipeBackAnchorTime: TimeInterval = 0 private var speedBeforeLongPress: Float? private var transientTask: Task? + private var cacheSwapTask: Task? + private var activePlaybackURL: URL? private var sentenceEndTimeObserver: Any? private var sentenceEndGuardToken = 0 @@ -79,6 +85,7 @@ final class PlayerController: ObservableObject { // MARK: - 装载 + @MainActor func load(_ item: TrainingMediaItem) { cancelSentenceBoundaryGuard() lastSentenceIndex = nil @@ -89,18 +96,51 @@ final class PlayerController: ObservableObject { mediaItem = item currentItem = item boundaryState = .idle - let playerItem = AVPlayerItem(url: item.url) + let playbackURL: URL + if item.isLocalFile { + playbackURL = item.url + } else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) { + playbackURL = cachedURL + } else { + playbackURL = item.url + } + + activePlaybackURL = playbackURL + let playerItem = AVPlayerItem(url: playbackURL) attachItemObservers(to: playerItem) player.replaceCurrentItem(with: playerItem) if config.autoPlay { player.play() } + playbackIntent = config.autoPlay + scheduleCachePrefetchIfNeeded(for: item, activeURL: playbackURL) + tick() + } + + /// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。 + /// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。 + @MainActor + func updateBoundaries(_ sentences: [SentenceBoundary]) { + guard let oldItem = mediaItem else { + return + } + let newItem = TrainingMediaItem( + id: oldItem.id, + title: oldItem.title, + url: oldItem.url, + sentences: sentences, + isLocalFile: oldItem.isLocalFile + ) + mediaItem = newItem + currentItem = newItem + lastSentenceIndex = nil tick() } // MARK: - 播放控制 func play() { + playbackIntent = true player.play() // play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用; // 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。 @@ -110,6 +150,7 @@ final class PlayerController: ObservableObject { func pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false cancelSentenceBoundaryGuard() player.pause() } @@ -129,6 +170,7 @@ final class PlayerController: ObservableObject { func stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false cancelSentenceBoundaryGuard() player.pause() seek(to: .zero) @@ -423,10 +465,52 @@ final class PlayerController: ObservableObject { pendingSeekPositionMs ?? currentPositionMs() } + @MainActor + private func scheduleCachePrefetchIfNeeded(for item: TrainingMediaItem, activeURL: URL) { + cacheSwapTask?.cancel() + cacheSwapTask = nil + guard !item.isLocalFile, + activeURL != item.url else { + return + } + + let remoteURL = item.url + cacheSwapTask = Task { [weak self] in + guard let cachedURL = await StreamingVideoCache.shared.prefetch(remoteURL) else { + return + } + self?.swapToCachedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL) + } + } + + @MainActor + private func swapToCachedFileIfNeeded(remoteURL: URL, cachedURL: URL) { + guard !Task.isCancelled, + mediaItem?.url == remoteURL, + activePlaybackURL != cachedURL else { + return + } + + let targetPositionMs = pendingSeekPositionMs ?? currentPositionMs() + let newItem = AVPlayerItem(url: cachedURL) + activePlaybackURL = cachedURL + + // 递增 seek generation,让旧远程 Item 上未完成的 seek 回调失效。 + seekGeneration += 1 + attachItemObservers(to: newItem) + player.replaceCurrentItem(with: newItem) + performSeek(to: targetPositionMs) + } + /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 - private func performSeek(to positionMs: Int64, completion: (() -> Void)? = nil) { + private func performSeek( + to positionMs: Int64, + resumePlayback: Bool? = nil, + completion: (() -> Void)? = nil + ) { let targetPositionMs = max(0, positionMs) + let shouldResumePlayback = resumePlayback ?? playbackIntent seekGeneration += 1 let generation = seekGeneration pendingSeekPositionMs = targetPositionMs @@ -441,6 +525,9 @@ final class PlayerController: ObservableObject { } self.pendingSeekPositionMs = nil self.tick() + if shouldResumePlayback { + self.player.play() + } completion?() } } @@ -628,6 +715,7 @@ final class PlayerController: ObservableObject { if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false player.pause() seekRaw(sentenceSeekPosition(sentence)) tick() @@ -687,8 +775,9 @@ final class PlayerController: ObservableObject { tick() return } + // 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停, + // 则不应因为到达 media end 而突然自动循环。 performSeek(to: .zero) - player.play() tick() } diff --git a/ios/OralTrainer/Services/StreamingVideoCache.swift b/ios/OralTrainer/Services/StreamingVideoCache.swift new file mode 100644 index 0000000..aae5b55 --- /dev/null +++ b/ios/OralTrainer/Services/StreamingVideoCache.swift @@ -0,0 +1,188 @@ +import CryptoKit +import Foundation + +/// iOS 端的持久化整文件视频缓存。 +/// +/// Android 端使用 ExoPlayer 的 CacheDataSource 边播边缓存;AVFoundation 没有公开 +/// 等价的持久字节缓存 API。这里在后台把远程 MP4 下载成完整本地文件,下次装载时 +/// 直接交给 AVPlayer,并在本次播放完成下载后热切换到本地文件,避免回跳句子时 +/// 再次依赖网络。 +@MainActor +final class StreamingVideoCache { + static let shared = StreamingVideoCache() + + static let maxCacheBytes: Int64 = 512 * 1024 * 1024 + + private static let supportedExtensions: Set = ["mp4", "m4v", "mov"] + private static let fileExtension = "mp4" + + private let directory: URL + private let fileManager = FileManager.default + private var downloadsByURL: [URL: Task] = [:] + + private init() { + let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + directory = caches.appendingPathComponent("oral-trainer-streaming-videos", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func cachedFileIfAvailable(for remoteURL: URL) -> URL? { + guard isCacheable(remoteURL), let file = destinationFile(for: remoteURL) else { + return nil + } + guard fileManager.fileExists(atPath: file.path), isUsableMediaFile(file) else { + fileManager.removeItemIfPossible(at: file) + return nil + } + + // AVPlayer 读取后刷新 LRU 时间,与 Android LeastRecentlyUsedCacheEvictor 的 + // 语义保持接近。 + try? fileManager.setAttributes( + [.modificationDate: Date()], + ofItemAtPath: file.path + ) + return file + } + + /// 重复调用同一 URL 时复用同一个后台下载;完成后返回本地文件。 + func prefetch(_ remoteURL: URL) async -> URL? { + if let cached = cachedFileIfAvailable(for: remoteURL) { + return cached + } + guard isCacheable(remoteURL), let destination = destinationFile(for: remoteURL) else { + return nil + } + + if let existing = downloadsByURL[remoteURL] { + return await existing.value + } + + let task = Task { + await Self.download(from: remoteURL, to: destination) + } + downloadsByURL[remoteURL] = task + + let result = await task.value + if downloadsByURL[remoteURL]?.isCancelled != true { + downloadsByURL[remoteURL] = nil + } + if result != nil { + evictIfNeeded(keeping: remoteURL) + } + return result + } + + func clear() { + for task in downloadsByURL.values { + task.cancel() + } + downloadsByURL.removeAll() + try? fileManager.removeItem(at: directory) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + private func isCacheable(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + let ext = url.pathExtension.lowercased() + // HLS/DASH 播放列表不能通过下载单个清单完成离线缓存;当前云端课程均为 MP4。 + return Self.supportedExtensions.contains(ext) + } + + private func destinationFile(for remoteURL: URL) -> URL? { + let rawData = Data(remoteURL.absoluteString.utf8) + let digest = SHA256.hash(data: rawData) + let key = digest.map { String(format: "%02x", $0) }.joined() + return directory.appendingPathComponent(key).appendingPathExtension(Self.fileExtension) + } + + private func isUsableMediaFile(_ url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile == true, let fileSize = values?.fileSize else { + return false + } + let size = Int64(fileSize) + return size > 0 + } + + private func evictIfNeeded(keeping remoteURL: URL) { + guard let keeping = destinationFile(for: remoteURL) else { + return + } + + let contents = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles] + )) ?? [] + + var entries: [(url: URL, size: Int64, date: Date)] = contents.compactMap { url in + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + guard let fileSize = values?.fileSize, fileSize > 0 else { + fileManager.removeItemIfPossible(at: url) + return nil + } + let size = Int64(fileSize) + return (url, size, values?.contentModificationDate ?? .distantPast) + } + + var totalBytes = entries.reduce(0) { $0 + $1.size } + entries.sort { $0.date < $1.date } + for entry in entries where totalBytes > Self.maxCacheBytes && entry.url != keeping { + guard fileManager.removeItemIfPossible(at: entry.url) else { + continue + } + totalBytes -= entry.size + } + } + + private static func download(from remoteURL: URL, to destination: URL) async -> URL? { + var request = URLRequest(url: remoteURL) + request.timeoutInterval = AppConfig.connectTimeout + request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent") + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let (tempFile, response) = try await URLSession.shared.download(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + FileManager.default.removeItemIfPossible(at: tempFile) + logFailure(remoteURL, message: "HTTP \(status)") + return nil + } + + let fileManager = FileManager.default + try? fileManager.removeItem(at: destination) + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.moveItem(at: tempFile, to: destination) + return destination + } catch is CancellationError { + return nil + } catch { + logFailure(remoteURL, message: error.localizedDescription) + return nil + } + } + + private static func logFailure(_ url: URL, message: String) { + #if DEBUG + print("[StreamingVideoCache] 预缓存失败: \(url), \(message)") + #endif + } +} + +private extension FileManager { + @discardableResult + func removeItemIfPossible(at url: URL) -> Bool { + do { + try removeItem(at: url) + return true + } catch { + return false + } + } +} diff --git a/ios/OralTrainer/Views/CatalogView.swift b/ios/OralTrainer/Views/CatalogView.swift index 8bbf0e5..542b654 100644 --- a/ios/OralTrainer/Views/CatalogView.swift +++ b/ios/OralTrainer/Views/CatalogView.swift @@ -51,13 +51,7 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == video.videoHash else { return } - let item = TrainingMediaItem( - id: video.videoHash, - title: video.title, - url: url, - sentences: document.sentences - ) - controller.load(item) + controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") } catch { @@ -101,14 +95,7 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == hash else { return } - let loadedItem = TrainingMediaItem( - id: hash, - title: fileName, - url: tempURL, - sentences: document.sentences, - isLocalFile: true - ) - controller.load(loadedItem) + controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句") } catch {