From 004e4be4d36c833572bf27c363bf5d9c5c36501f Mon Sep 17 00:00:00 2001 From: quonverbat Date: Wed, 2 Apr 2025 00:32:10 +0300 Subject: [PATCH 01/46] Added Turkish Translations --- app/src/main/res/values-tr/strings.xml | 1059 ++++++++++++++++++++++++ 1 file changed, 1059 insertions(+) create mode 100644 app/src/main/res/values-tr/strings.xml diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..bb0d98a5 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,1059 @@ + + Yayınla + Ara + Sorguya Ekle + Thumbnail + Kanal Resmi + Ekle + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Sıraya Ekle + Geçmişe Ekle + Genel + Kanal + Ana Sayfa + İlerleme Çubuğu + Tarihsel bir ilerleme çubuğu gösterilecekse + Aramada, ana sayfada gizlenmişleri gizle + Ana sayfada gizlenmiş içerik üreticilerini ve videoları arama sonuçlarında da gizle + Önerilenler + Daha Fazla + Oynatma Listeleri + Abonelikler + Yükleniyor + Yeniden Dene + İptal et + Veri alınamadı, internete bağlı mısınız? + Ayarlar + Geçmiş + Kaynaklar + Satın Al + SSS + Gizlilik Modu + En üstteki kaynak birincil olarak kabul edilecektir + Varsayılanlar + Ana Sayfa + Tercih Edilen Kalite + Bir videoyu izlemek için varsayılan kalite + Güncelle + Kapat + Asla + İçe aktarma seçeneklerinden birini seçin. + Bir güncelleme mevcut, uygulamayı güncellemek ister misiniz? + Güncelleme indiriliyor… + Güncelleme kuruluyor… + Tamamlandı + getHome + Başarılı + Paketi güncellenemedi hata> + İşlem genel bir şekilde başarısız oldu + İşlem aktif olarak iptal edildiği için başarısız oldu + İşlem bloklandığı için başarısız oldu + İşlem, cihazda zaten yüklü olan başka bir paketle çakıştığı için başarısız oldu + İşlem cihazla temel olarak uyumlu olmadığı için başarısız oldu + İşlem bir ya da daha fazla APK geçersiz olduğundan başarısız oldu + İşlem depolama sorunları yüzünden başarısız oldu + CANLI + Canlı + Daha fazla okumak için dokunun + Gizlemek için dokunun + Versiyon + Göre sırala + Abone ol + Abonelikten çık + Arka Plan + Video + Ekle + İndir + Paylaş + Hepsini görüntüle + Üreticiler + Etkin + Ekranı açık tut + Yayın sırasında ekranı açık tut + Her zaman proxy istekleri + Cihaz üzerinden veri yayınlarken her zaman proxy istekleri. + Keşfet + Eklemek için yeni video kaynakları bulun + Bu kaynaklar devre dışı bırakılmıştır + Bunlar bu grup için görünür olan üreticilerdir + Bu üreticiler bu grupta değiller + Devre dışı + Daha sonra izle + Oluştur + Tamam + Evet + Hayır + Onayla + Tekrar sorma + Bu oynatma listesini silmek istediğinizden emin misiniz? + Bu aboneliği kaldırmak istediğinizden emın misiniz? + Bu kaynağın kaldırılması aboneliklerinizin bazılarının çözümlenmemesine neden olacaktır. + Karıştır + Hepsini oynat + Arama geçmişi + Uygulama Hatası + Geliştirici + Geçmiş önerisini kaldır + Yorumlar + İçeriğin altındaki yorum bölümü + Alışveriş + Oynatma listesinin sonuna ulaşıldı + Oynatma listesi video sona erdikten sonra yeniden başlatılacak + Şimdi yeniden başlat + Sıranın sonuna ulaşıldı + Oynatma listesinin sonuna ulaşıldı + Daha Sonra İzle\'nin sonuna ulaşıldı + Sıranın sonuna ulaşıldı + Sıra video sona erdikten sonra yeniden başlatılacak + Sonraki + Sıra + Temizle + Son saat + Son 24 saat + Son hafta + Son 30 gün + Son yıl + Tüm zamanlar + Bu geçmiş girdileri kaldırmak istediğinizden emin misiniz? + kaldırıldı + Kaynak ekle + Platform URL + Repository URL + Kod URL + Konfigürasyon URL + Bunlar eklentinin çalışabilmesi için gereken izinlerdir + Eklenti değerlendirme kapasitesine erişebilecek + Eklenti şu domainlere erişim sahibi olacak + QR Kodu Okut + İndirmek için bir QR kodu okutun + Eklentinin konfigürasyonunu yüklemek için bir URL girin + URL gir + Kur + Hiçbir cihaz bulunamadı. Cihazınızın görünmesi biraz zaman alabilir, lütfen sabırlı olun + Hazır değil + Bağlan + Dur + Başlat + Depolama Alanı + İndirmeler + Hepsinin seçimini kaldır + Hepsini seç + Yayınlama cihazı ekle + Yeni aktivite + Üretici NeoPass\'te + Seçenekler + Dışa aktar + Kaldır + Güncelleme yükleniyor… + Çevrimiçi videolara göz atarken ve yorum bırakmak istediğinizde, çevrimiçi varlığınızı yönetmenin hem kolay hem de ihtiyaçlarınıza odaklanmış yeni bir yolu olan Polycentric\'i deneyin. İşte bir Polycentric profili oluşturmanın harika bir seçim olmasının nedenleri:\n\n 1. Kontrol Sizde: Polycentric ile verileriniz tek bir şirket tarafından kontrol edilen tek bir yerde saklanmaz. Bunun yerine, birden fazla konuma yayılır ve çevrimiçi varlığınız üzerinde daha fazla kontrol sahibi olursunuz. Verilerinizin nerede saklanacağına siz karar verirsiniz ve sağlayıcılar arasında kolayca geçiş yapabilir veya yeni sağlayıcılar ekleyebilirsiniz.\n\n 2. Gizlilik ve Güvenlik: Polycentric, gelişmiş güvenlik teknikleri kullanarak bilgilerinizi güvende tutar. Kişisel bilgilerinizin iyi korunduğundan ve yalnızca bunları paylaşmayı seçtiğiniz kişiler tarafından erişilebilir olduğundan emin olabilirsiniz.\n\n 3. Sorunsuz Ağ Oluşturma: Polycentric, tek bir platforma bağlı kalmadan başkalarıyla bağlantı kurmanızı ve içerikleriyle etkileşim kurmanızı sağlar. Bir sağlayıcı güvenilmez hale gelirse veya erişimi engellemeye çalışırsa, Polycentric istemciniz otomatik olarak diğer kaynaklardan bilgi bularak sizi bağlı tutar.\n\n 4. Akıllı Arama ve Öneriler: Polycentric, arama ve öneriler için birden fazla kaynak kullanır ve size en üst düzey performansı sunarken hiçbir sağlayıcının gördüğünüz sonuçlar üzerinde fazla etkisi olmamasını sağlar.\n\n 5. Uyumlu ve Açık: Polycentric, kullanıcılarının ihtiyaçlarına uyum sağlayan sürekli iyileştirmeler ve yeni özellikler sunarak esnek ve yeni gelişmelere açık olacak şekilde tasarlanmıştır.\n\n 6. Bir Polycentric profili oluşturarak çevrimiçi videolara yorum bırakabilir ve daha kişiselleştirilmiş bir çevrimiçi deneyimin keyfini çıkarabilirsiniz. Bu kullanıcı dostu çözümün dijital hayatınızın kontrolünü nasıl size verdiğini kendiniz görün. Bugün Polycentric\'e kaydolun! + Yayınla + Kullanım + Ana sayfada etkinleştir + Aramada etkinleştir + Kontrol et + Grayjay yapımı ve bakımı kolay ya da ucuz bir uygulama değildir. Uygulamada ve diğer sistemlerde tam zamanlı çalışan mühendislerimiz var. Ve parasını yakın zamanda -belki hiç- kazanamayacağız.\n\nFUTO\'nun misyonu, açık kaynaklı yazılım ve kötü amaçlı olmayan yazılım iş uygulamalarının projeler ve geliştiricileri için sürdürülebilir bir gelir kaynağı haline gelmesidir. Bu nedenle kullanıcıların yazılım için gerçekten ödeme yapmasından yanayız.\n\nBu yüzden Grayjay, yazılım için ödeme yapmanızı istiyor. + Bir hata oluştu, rahatsızlıktan dolayı özür dileriz. + Bunu çözmemize yardım etmek için, lütfen çökme raporunu aşağıda bizimle paylaşın, ve sorunu ne yaparken yaşadığınız hakkında bilgi verin. + Yeni Profil + Varolan Profili İçe Aktar + Yeni bir kişilik oluşturun + Varolan kişiliği kullanın + İzinler + Güvenlik Uyarıları + Bunlar eklenti davranışına dair uyarılardır + Lütfen CAPTCHA\'yı girin ve sona erdiğinde kapatın + KAPAT + Gönder + Yeniden Başlat + Sekmeleri Düzenle + İçe aktarmak için okutun + Kişiliğini başka bir uygulamaya gönder + Kopyala + Kişiğini panoya kopyala + Polycentric + Profil İsmi + Bu diğer kullanıcılara görünecek + Profil Oluştur + YA DA + Profilinizi buraya yapıştırın polycentric://… + Profili içe aktar + Görünüşe göre bir geliştiricisiniz + Geliştirici Ayarları + Migrasyon + Günlük yedeklemeniz için bir şifre koyun + Harici depolamaya yazılmış günlük yedeklemenizi şifrelemek için bir şifre koyun + Yedek Şifre + Şifre Tekrar + Otomatik yedeklemeden restore edin + Görünüşe göre cihazınızda bir otomatik yedekleme var, eğer restore etmek isterseniz, yedek şifrenizi girin. + Restore Edin + Hata Mesajı + İsim + IP + Port + Keşfedilmiş Cihazlar + Hatırlanan Cihazlar + Hatırlanan cihaz yok + Şuna bağlan + Ses + Değişiklikler + Bu versiyon ve önceki versiyonlar için var olan değişiklikleri gösterir + Örnek bir değişiklik. + Önceki + Sonraki + Yorum Yap + Yorum boş değil, yine de kapatmak ister misiniz? + İçe aktar + Benim oynatma listesi adım + Bu belleği içe aktarmak ister misiniz? + Etkinleştirilmesi gereken kaynaklar + Ögelerin taşınması gerekiyor veya bozulmuşlar, bunları şimdi yedekten geri yüklemek ister misiniz? + Yok sayılırsa bir sonraki başlatmada tekrar sorulacaktır. + Yok say + Değişiklikleri görüntüle + Zaten ödedim + Üyelikler + Sık sık tekrarlanan aylık ödeme + ek avantajlar + Üreticiye destek olmak için tek seferlik ödeme + Üreticinin mağazası + Bağış + Promosyonlar + Bu yaratıcının güncel promosyonları + İndiriliyor + Videolar + Geçmişi temizle + İçe aktarılacak bir şey yok + Herhangi bir kaynak indirmediniz, lütfen uygulamayı amaçlandığı şekilde kullanmak için kaynak ekleyin. + Birçok kaynak eklemek uygulamanızın yüklenme hızını yavaşlatabilir. + Destek + Üyelik + Mağaza + Canlı Sohbet + Kaldır + Videolar + Oynatma listesi + Polycentric kanal + Etkinleştir + Yapım aşamasında + ALTINDA + YAPIM + Devre dışı bırak + Eksik eklenti yüzünden bu içerik Grayjay\'de oynatılamaz. + Bu içerik kilitli + Bilinmeyen + Tarayıcıda açmak için dokunun + Eksik Eklenti + İzleyiciler baskın yapıyor + Şimdi git + Engel ol + Son Zamanlarda Kullanılan Oynatma Listesi + Lisans e-postası + Lisans anahtarını göndermek için gerekli + Makbuz e-postası (kullanıcı@domain.com) + Şu şekilde ödeme + Ülke + Posta Kodu + Grayjay + Satış Vergisi ( + Toplam + Ödeme + Standard ödeme yolları + Stripe, başlıca kredi kartlarını, banka kartlarını ve çeşitli yerelleştirilmiş ödeme yöntemlerini kabul eden güvenli bir çevrimiçi ödeme hizmetidir. + Bir yorum ekle… + İlgilenmiyorum + Yüklemek için QR kodu okutun + Tam ekrana geç + Tarafından + İmza + Geçerli + Tekrar et + Görüntüle + QR kod ile indirin + QR kodu okutarak bir eklenti indirin + Çıkış Yap + Video + Daha detaylı bir video görüntüleyin + Teknik Dokümantasyon + Teknik dokümantasyonu görüntüleyin + Mağazamı ziyaret edin + Kişiliğinizin bir yedeklemesini oluşturun + Bu kişilikten çıkış yap + Bu profili sil + URL ile yükleyin + Önceden yüklenmiş videoları daha hızlı yüklemek için önbelleğe alın + Kullanıcılar tarafından ve kendi bildirilen raporların bir listesi + Ayrıca oturum açma veya ayarlar gibi verilerle ilgili tüm eklentileri kaldırır + Duyuru + Bildirimler + Güncellemeler için devre dışı bırakılmış eklentileri kontrol edin + Güncellemeler için devre dışı bırakılmış eklentileri kontrol edin + Planlanmış İçerik Bildirimleri + Keşfedilen planlanmış içerikleri bildirim olarak zamanlar, bu sayede bu içerik için daha doğru bildirimler verir. + Bayt aralıklarını kullanmaya çalışın + Otomatik Güncelleme + Ters yatay otomatik döndürmeye her zaman izin ver + Sistem ayarlarından otomatik döndürmeyi devre dışı bıraksanız bile, tam ekran modunda iki yatay yön arasında her zaman otomatik döndürme olacaktır. + Kaynakları basitleştir + Çözünürlük yoluyla kaynaklar çoğaltılır, böylece yalnızca alakalı kaynaklar görünür. + Otomatik Yedekleme + Arka Plan Davranışı + Arka Plan Güncellemesi + Arka Plan İndirmesi + Yedekleme + Göz Atma + ByteRange Concurrency + ByteRange İndirmesi + Yayınlama + Oynatıcının davranışını değiştir + Harici indirilenler klasörünü değiştir + Harici indirilenler klasörünü temizle + Harici genel klasörünü değiştir + Ana sayfada görünen sekmeleri değiştir + Link Kontrolü + Grayjay\'in linkleri yönetmesine izin ver + Genel dosyalar için harici klasörü değiştir + İndirilen dosyalar için harici depolama alanını temizle + İndirilen dosyalar için harici depolama alanını değiştir + Çerezleri Temizle + Çıkışta Çerezleri Temizle + Arka Plan İşlerini Test Et + + Ödemeyi Temizle + Çıkış yaptığınızda çerezleri temizler + Uygulama-içi tarayıcı çerezlerini temizler + Göz atma davranışını özelleştir + Zaman çubuğu + Tarihsel zaman çubuklarının gösterimini özelleştir + Yayınlamayı özelleştir + Felaket niteliğinde bir arıza durumunda günlük yedeklemeyi özelleştir + Videoların indirilmesini özelleştir + Ana sayfa sekmenizin nasıl çalışacağını ve görüneceğini özelleştirin + Abonelikler sekmenizin nasıl çalışacağını ve görüneceğini özelleştirin + Arka planda indirmenin kullanılıp kullanılmayacağını özelleştirin + Otomatik güncelleştiriciyi özelleştirin + Güncellemelerin ne zaman indirileceğini özelleştirin + Videoların ne zaman indirileceğini özelleştirin + Grayjay ile açarak içe aktarabileceğiniz verilerinizi içeren bir zip dosyası oluşturur + Varsayılan Ses Kalitesi + Varsayılan Oynatma Hızı + Varsayılan Video Kalitesi + Uygulamadan lisans anahtarlarını sil + Ne zaman indir + Video Önbelleğini Etkinleştir + Yayınlamayı etkinleştir + Abonelik önbelleği için deneysel arka plan güncellemesi + Veriyi Dışa Aktar + Veriyi İçe Aktar + İçe aktarmak için bir dosya seçin, çeşitli dosyaları destekler (doğrudan açmak yerine) + Harici Depolama + Feed Stili + Dil + Uygulama Dili + Yeniden başlatma gerektirebilir + Uygulama başlatıldığında al + Sekme açıldığında al + Sekme açıldığında yeni sonuçlar alın (henüz sonuç yoksa ve sorun yaşamıyorsanız, devre dışı bırakılması önerilmez) + Her zaman önbellekten yeniden yükle + Bu önerilmez, ama bazı sorunlar için geçici bir çözümdür. + Kanal İçeriklerine Göz At + Eğer eklenti tarafından destekleniyorsa, kanal içeriğini önizleyin. Oran sınırlı çağrılar nedeniyle, bu işlem abonelik yenileme süresini artırabilir. + Çok sorulan sorulara cevap al + Uygulamaya geri bildirimde bulunun + Bilgi + + Senkronizasyon + Özelliği etkinleştir + Yayın + Cihazın varlığını yayınlamasına izin ver + Keşfedilenlere bağlan + Cihazın bilinen eşleştirilmiş cihazları aramasına ve onlarla bağlantı başlatmasına izin ver + Son bağlanılana bağlan + Cihazın son bilinen cihaza otomatik olarak bağlanmasına izin ver + Hareket Kontrolleri + Ses çubuğu + Kaydırma hareketinin sesi değiştirmesine izin ver + Parlaklık çubuğu + Kayrdırma hareketinin parlaklığı değiştirmesine izin ver + Tam ekrana geç + Kaydırma hareketinin tam ekrana geçmesine izin ver + Sistem parlaklığı + Hareket kontrolleri sistem parlaklığını değiştirir + Sistem parlaklığını eski haline getir + Sistem parlaklığını tam ekrandan çıkarken eski haline getir + Yakınlaştırmayı etkinleştir + İki parmak kaydırma hareketi ile yakınlaştırmayı etkinleştir + Görüntüyü kaydırmayı etkinleştir + İki parmak kaydırma hareketi ile görüntüyü dikey ve yatay olarak kaydırmayı etkinleştir + Sistem sesi + Hareket kontrolleri sistem sesini değiştirir + Canlı Chat Web Görüntüsü + Tam Ekran Portre + Ters portreye izin ver + Uygulamanın ters portreye dönmesine izin ver + Döndürme bölgesi + Döndürme bölgelerinin hassasiyetini belirleyin (daha az hassas yapmak için azaltın) + Stabilite eşik süresi + Bir dönüşü tetiklemek için yönelimin aynı olması gereken süreyi belirtin + Webm Video Kodeklerini Tercih Edin + Eğer oynatıcı mp4 kodeklerini (h264/AAC), Webm kodeklerine (vp9/opus) tercih ederse daha kötü bir uyumluluğa yol açabilir. + Tam otomatik döndürme kilidi + Döndürme kilidi açıksa herhangi bir döndürmeye engel olur (yatay ve yatay ters arasında geçişte bile). + Webm Audio Kodeklerini Tercih Et + Eğer oynatıcı mp4 kodeklerini (AAC), Webm kodeklerine (opus) tercih ederse daha kötü bir uyumluluğa yol açabilir. + Çerçeve altında videoya izin ver + Videonun tam ekranda ekran çerçevesinin altına gitmesine izin ver.\nYeniden başlatma gerektirebilir + Otomatik olarak sonraki videoyu oynat + Bir videoyu izlerken sonraki videoyu otomatik olarak oynatma varsayılan olacaktır + Yatay videolar izlerken tam ekran portreye izin ver + İzlendikten sonra Daha Sonra İzle\'den kaldır + Büyük bir çoğunluğunu izlediğiniz bir videoyu Daha Sonra İzle\'de bırakırsanız, Daha Sonra İzle\'den kaldırılacaktır. + Arka planda sese değiştir + Mümkünse arka planda yalnızca ses akışına geçerek bant genişliği kullanımını optimize edin, bu takılmalara neden olabilir + Gruplar + Abonelik Gruplarını Göster + Abonelik gruplarının filtrelemek için aboneliklerinizin üzerinde gösterilmesi gerekiyorsa + Feed Ögelerini Önizle + Önizleme feed stili kullanıldığında, ögelerin üzerinden kaydırırken otomatik olarak önizlenir. + Log Seviyesi + Loglama + Grayjay\'i Senkronize Et + Verilerinizi birden fazla cihaz arasında senkronize edin + Polycentric kişiliği düzenle + Polycentric kişiliğinizi düzenleyin + Manuel kontrol + Güncellemeleri manuel olarak kontrol edin + Hız sınırlandırılmış kaynaklardan indirme hızlarını artırmak için eşzamanlı thread sayısı. + Ödeme + Ödeme Durumu + Döndürme Engelini Baypas Et + Oynatma Listesi Silme Onayı + Bir oynatma listesinden bir medya silerken onay diyaloğu göster + Kopya oynatma listesi videolarına izin ver + Bir videonun oynatma listelerine birçok kere eklenmesine izin ver + Polycentric\'i Etkinleştir + Polycentric Lokal Önbelleğe İzin Ver + Yükleme sürelerini azaltmak için Polycentric sonuçlarını cihazda önbelleğe alır, değişiklik uygulamanın yeniden başlatılmasını gerektirir + Sorun yaşıyorsanız devre dışı bırakılabilir + Video olmayan görüntülerde döndürmeyi etkinleştirir.\nUYARI: Bunun için tasarlanmamıştır + Bu beklenmeyen davranışlara neden olabilir ve çoğunlukla test edilmemiştir. + Bu bölümü değiştirmek uygulamanın yeniden başlatılmasını gerektirmektedir. + Oynatıcı + Eklentiler + Tercih Edilen Yayınlama Kalitesi + Harici bir cihaza yayınlarken varsayılan kalite + Tercih Edilen Kalite (Ölçülü) + Hücresel gibi ölçülü bağlantılarda varsayılan kalite + Tercih Edilen Önizleme Kalitesi + Bir videoyu önizlerken varsayılan kalite + Birincil Dil + Orijinal Sesi Tercih Et + Tercih edilen dil bilindiğinde bunun yerine orijinal sesi kullanın + Varsayılan Yorum Bölümü + Önerilenleri Gizle + Önerilenler sekmesini tamamen gizle. + Varsayılan Olarak Önerilenler + Varsayılan olarak yorumlar yerine, önerilenleri gösterir. + Kötü İtibara Sahip Yorumları Gösterme + Kötü itibara sahip yorumların gösterilip gösterilmemesi. Devre dışı bırakmak deneyimi kötüleştirebilir. + Gömülü Eklentileri Yeniden İndir + Önbelleğe Alınmış Versiyonu Kaldır + Son indirilmiş versiyonu kaldır + Gizli Ögeleri Temizle + Gizlenmiş üreticileri ve videoları temizleyerek onları yeniden gösterir + Duyuruları sıfırla + Gizli duyuruları sıfırla + Otomatik Yedeklemeyi Restore Et + Bir önceki otomatik yedeklemeyi restore et + Önizlemeden Sonra Devam Et + Şu anki ve önceki değişiklikleri görüntüle + Ses odak kaybından sonra yeniden başlat + Bir ses kayıptan sonra ses odağı kazanıldığında oynatmayı yeniden başlat + Bağlantı kaybından sonra yeniden başlat + Bir bağlantı kayıptan sonra bağlantı sağlandığında oynatmayı yeniden başlat + Bölüm Güncelleme FPS\'i + Bölüm güncellemesinin doğruluğunu değiştirin, daha yüksek olması daha fazla performansa mal olabilir + Otomatik Yedeklemeyi Ayarlayın + Uygulamayı açtıktan kısa bir süre sonra, abonelikleri almaya başla + Sıkça Sorulan Soruları (SSS) Göster + Sorunları Göster + Kanalları almak için kaç thread kullanılacağını belirtin + Geri bildirim gönder + Logları gönder + Kanal Önbelleğini Temizle + Abonelik kanalı önbelleğinden tüm içeriği siler + Sorunları azaltmamızda bize yardım etmek için logları gönderin + Abonelik Eşzamanlılığı + Oynatma Süresini Lokal Olarak Takip Et + Aboneliklerin oynatma süresini lokal olarak takip edin, abonelikleri sıralarken ve üretici önerilirken kullanılır. + İzleme Ölçümlerini Göster + Her üreticinin oynatma süresini ve oynatma sayısını üreticiler sekmesinde gösterir + Bu verilen dereceye göre cihazın dönmesine engel olur + Eğer varsa yerel uygulaması yerine canlı chat web penceresini kullanın + Versiyon Kodu + Versiyon İsmi + Versiyon Tipi + Kanal Önbellek Büyüklüğü (Başlat) + Önizleme modunda video izlerken, videoyu açarken o konumdan devam et + Lütfen log göndermek logları etkinleştirin + Gömülü eklentiler yeniden kuruldu, uygulamayı yeniden başlatmanız önerilir + Duyurular sıfırlandı. + Mağazayı gösterme başarısız oldu. + Değişiklikler alınıyor + Ödendi + Ödenmedi + Lisanslar temizlendi, bu, uygulamanın yeniden başlatılmasını gerektirebilir + getHome\'dan 2 sayfa alma denemeleri + Arka Plan Abonelik Testi + Bütün İndirilenleri Temizle + İndirilenleri Temizle + CookieManager\'dan bütün çerezleri temizle + Beni Çökert + Uygulamayı bilerek çökertir + Duyuruları Sil + Çözümlenmemişleri Sil + Tüm duyuruları sil + Tüm indirilen videoları ve ilgili dosyaları siler + Devam eden indirilenleri siler + Çözümlenmemiş kaynak dosyalarını siler + Geliştirici Modu + Tüm Sertifikalara İzin Ver + Bu, Grayjay ağ trafiğinizin tamamının açığa çıkma riski taşır. + Geliştirme Sunucusu + Deneysel + Önbellek + Depoyu hata alana kadar doldur + Enjekte Et + V8\'e bir test kaynak konfigürasyonu (local) enjekte eder + Diğer + Diğerleri… + Tüm abonelikleri siler + Geliştirme sunucusu ile ilgili ayarlar, telefonunuzu güvenlik açıklarına maruz bırakabileceğinden dikkatli olun + Sunucuyu Başlat + Oynatıcıyı Test Et + Tüm videoları oynatmaya devam eder + Abonelik Önbelleği 5000 + Geçmiş Önbelleği 100 + Başlangıçta sunucuyu başlat + Port 11337\'de bir DevServer başlatır, güvenlik açıklarını ortaya çıkarabilir. + V8 iletişim hızını test et + V8 oluşum hızını test et + V8 iletişim hızını test eder + V8 oluşturma sürelerini ve çalıştırmayı test eder + Tümünü abonelikten çık + V8 Denektaşları + V8 Script Testleri + Entegre V8 motorunu kullanan çeşitli kıyaslamalar + Özel bir kaynağa karşı çeşitli testler + Diskte yer kalmayana kadar yazar + Görünürlük + Güncellemeleri kontrol et + Bir eklentinin başlangıçta güncellemeler açısından kontrol edilip edilmemesi + Otomatik Güncelleme + Hiçbir izin değiştirilmemişse ve eklenti etkinleştirilmişse başlangıç sırasında otomatik olarak güncellenir + Geliştirici Gönderimlerine İzin Ver + Geliştiricinin sunucusuna veri göndermesine olanak tanır, hassas veriler içerebileceğinden dikkatli olun. + Geliştiriciye güvendiğinizden emin olun. Hassas verilere erişim kazanabilirler. Bunu yalnızca geliştiricinin bir hatayı düzeltmesine yardımcı olmaya çalışırken etkinleştirin. + Oran Sınırı + Bu eklentinin davranışının oran sınırlamasıyla ilgili ayarları + Aboneliklerin Oranını Sınırla + Yapılan abonelik isteklerinin miktarını sınırlayın + Bu eklentinin içeriğinin göründüğü yerlerde etkinleştirin + İçeriği ana sayfa sekmesinde göster + İçeriği arama sonuçlarında göster + Geçerli bir URL sağlanmadı.. + Geçersiz Konfigürasyon Formatı + Konfigürasyon alınamadı + Script alma başarısız oldu + URL Erişimi + Eklentinin şu domainlere erişimi olacak + Erişimi Değerlendir + Eklenti, değerlendirme yeteneğine (uzaktan enjeksiyon) erişebilecek + QR kod okuma başarısız oldu + Eklenti URL\'i değil + Bir QR kod okutun + Henüz tamamlanmadı. + Bilinmeyen Durum + Bir şeyler ters gitti… stack trace mi eksik? + Loglar zaten gönderildi. + Hiçbir log bulunamadı. + Otomatik paylaşım başarısız oldu, manuel paylaşmak ister misiniz? + {id} Paylaşıldı + VS\'de bilinmeyen hata (exception) + Geliştiricilere exception\'ı gönder… + Lisans anahtarınız ayarlandı!\nUygulamayı yeniden başlatmanız gerekebilir. + Geçersiz lisans formatı + Bilinmeyen içerik formatı + Bilinmeyen dosya formatı + Bilinmeyen Polycentric formatı + Bilinmeyen URL formatı + Dosya işleme başarısız oldu + Bilinmeyen yeniden yapılandırma türü + Metin belgesi işlenemedi + NewPipe abonelikleri işlenemedi + QR kod oluşturma başarısız oldu + Metni Paylaş + Metin Kopyalandı + En az 3 karakter uzunluğunda olmak zorunda. + Profil oluşturma başarısız oldu. + Sunucuların tam olarak geri doldurulması başarısız oldu. + Bu kişiliğe giriş yap + Metin alanı herhangi bir veri içermiyor + Geçerli bir URL değil + Bu profil zaten içe aktarıldı + Profil içe aktarılıramadı: + İstemci geri doldurulamadı: + Bu profili silmek istediğinizden emin misiniz? + İşlem tanıtıcısı ayarlanmadı. + İsim en az 3 karakter uzunluğunda olmalı + İşlem tanıtıcısı ayarlanmadı. + Görüntü okuma başarısız oldu + Değişiklikler kaydedildi + Değişiklikler senkronize edilemedi + Görüntü seçici iptal edildi + Artık geliştirici modundasınız + Abone + {name} şurada bulun + Alma başarısız oldu + Satın alımınız için teşekkürler, ödeme alındıktan sonra e-postanıza bir anahtar kodu gönderilecek! + Ödeme başarısız oldu + Ödeme başarılı + Lisans anahtarını girin + Geçersiz lisans anahtarı + Lisans + Anahtar etkinleştirme başarısız oldu + Bu kanalı destekleyen herhangi bir kaynak etkin değil + {channelName} kanalını bir oynatma listesine dönüştürmek ister misiniz? + Kanal dönüştürme başarısız oldu + Sayfa + Videoyu Senkronize Et + Gizle + Ana Sayfada Gizle + Üreticiyi Ana Sayfada Gizle + Feed\'i Sırayla Oynat + Bütün feed\'i oynat + Sıraya Eklendi + Zaten Sırada + Kullanıldı + Mevcut + Sonraki sayfayı yükleme başarısız oldu + {pluginName} eklentisi başarısız oldu:\n{message} + Eklenti buna bağlı olarak başarısız oldu: + Ana sayfayı alma başarısız oldu \nEklenti + Ana sayfayı alma başarısız oldu + Ana sayfa mevcut değil + Ana sayfa mevcut değil, lütfen internet bağlantınızı kontrol edin ve yeniden deneyin. + Oynatma Listelerini İçe Aktar + oynatma listeleri içe aktarıldı. + {index} arasından {size} tanesi seçildi + Abonelikleri İçe Aktar + abonelikler içe aktarıldı. + Oynatma listesini düzenle + Metin Olarak Paylaş + Bir video URL listesi olarak paylaş + İçe Aktarma Olarak Paylaş + Grayjay\'e ait bir içe aktarma dosyası olarak paylaş + Oynatma listesi yükleme başarısız oldu + Lütfen oynatma listesinin yüklenmesi için bekleyin + Oynatma listesi lokal olarak kopyalandı + İndirilmiş videoları silmek istediğinizden emin misiniz? + Yeni bir oynatma listesi oluştur + Yeni bir grup oluştur + Beklenen medya içeriği bulundu + Gönderi yükleme başarısız oldu. + yanıtlar + Yanıtlar + Eklenti ayarları kaydedildi + Eklenti ayarları + Bu ayarlar eklenti tarafından tanımlanmıştır + Kaynak yükleme başarısız oldu + Güncellemeleri kontrol et + Kaynağın yeni versiyonlarını kontrol eder + Doğrulama + Bu platformdan çıkış yap + Bu kaynaktan aboneliklerinizi içe aktarın + Bu kaynaktan oynatma listelerinizi içe aktarın + Giriş + Giriş Gerekli + Bu kaynağın platformuna giriş yapın + Yönetim + Kaldır + Eklentiyi uygulamadan kaldırır + CAPTCHA\'yı sil + Bu eklenti için kaydedilen CAPTCHA yanıtını siler + Oynatma listeleri alınamadı. + {subscriptionCount} adet kullanıcı aboneliği alındı. + Abonelikler alınamadı. + Kaldırmak istediğinizden emin misiniz? + Kaldırıldı + Güncellemeler kontrol edilemedi + Eklenti tamamen güncel + Oran Sınırı Uyarısı + Bu, çok sayıda aboneliğe daha iyi destek sağlayana kadar kullanıcıların oran sınırına ulaşmasını önlemek için geçici bir önlemdir. + \n\nŞu eklentiler için çok fazla aboneliğiniz var:\n + Gönderiler + Planlanmış + İzlenmiş + Hiçbir bir sonuç bulunamadı\nYenilemek için aşağı kaydırın + Overlay + Yenile + şu anda izlenen + görüntüleme + Planlanmış + Oynatma takibini alma başarısız oldu + Canlı etkinlikleri alırken istisna: + Canlı sohbet penceresini alırken istisna: + Canlı sohbet yüklenemedi + Desteklenmeyen yayın formatı + Medya yüklenemedi + Çevrim Dışı Oynatma + Medya Hatası + Medya kaynağı yetkisiz bir hatayla karşılaştı.\nBu, eklentinin yeniden yüklenmesiyle çözülebilir.\nYeniden yüklemek ister misiniz?\n(Deneysel) + Oynatma Oranı + Kalite + Çevrim Dışı Video + Çevrim Dışı Ses + Çevrim Dişi Altyazılar + Videoyu Yayınla + Sesi Yayınla + Ses + Altyazılar + Mevcut olmayan video + Bu video mevcut değil. + Sıranızdaki [{authorName}] tarafından [{videoName}] videosu mevcut değil. + Geri + Duraklat + Oynat + Videoyu durdurur + Videoyu devam ettirir + Kaynaksız video + Sıranızdakı [{authorName}] tarafından [{videoName}] videosu için gerekli kaynak etkin değildi, oynatım atlandı. + Video yüklenemedi (ScriptImplementationException) + Geçersiz video + Sıranızdaki [{authorName}] tarafından [{videoName}] videosu geçersizdi, oynatım atlandı. + Yaş sınırlaması olan video + Sıranızdaki [{authorName}] tarafından [{videoName}] videosunda yaş sınırlaması vardı, video erişilebilir olmadığından oynatım atlandı. + Video yüklenemedi (ScriptException) + Video yüklenemedi + Şu anda mevcut değil, {time}s sonra tekrar deneyin. + Canlı yayın için yeniden deneme başarısız oldu + Bu uygulama geliştirilme henüz aşamasında. Lütfen hata raporları gönderin ve birçok özelliğin eksik olduğunu anlayışla karşılayın. + Lütfen en az 1 karakter kullanın + Bu videoyu silmek istediğinizden emin misiniz? + Açmak için dokunun + Güncelleme mevcut! + izleniyor + şurada mevcut + saniye + Lütfen yorum yapmak için giriş yapın. + Yorum yapılamadı: + Limitsiz bağlantı bekleniyor + Son hata + Hata + Filtreler + izleyiciler + En az bir yanıt beklendi fakat sunucu tarafından hiç yanıt gönderilmedi. + Lütfen beğenmek için giriş yapın + Lütfen beğenmemek için giriş yapın + "Yorumlar yüklenemedi. " + Script mevcut değil + İmza geçerli + İmza geçersiz + İmza mevcut değil + "Abone Olundu " + "Abonelikten Çıkıldı " + Bir otomatik yedeklemeniz yok + Eski bir yedekleme mevcut + Bu yedeklemeyi restore etmek ister misiniz? + Geçersiz Kıl + Veri Denemesi + İndirme mevcut değil + (henüz) indirilebilir kaynak bulunmuyor + Hiç + Sadece Ses + Videoyu İndir + Videonun detayları alınıyor + İndirmenin detayları alınamadı + Hedef Çözünürlük + Hedef Bitrate + Düşük Bitrate + Yüksek Bitrate + Eylemler + Videoyu indir + Video Seçenekleri + Pinleri Değiştir + Hangi butonların pinleneceğine karar ver + Pinlerinizi sırayla seçin + Daha Fazla Seçenek + Kaydet + Bu üretici henüz Harbor (Polycentric) için bir destek seçeneği koymadı + Daha Fazla Yükle + Oran sınırınından kaçınmak için {requestCount} istekten sonra durdu, daha fazla yüklemek için Daha Fazla Yükle\'ye dokunun. + Bu üretici henüz bir para kazanma yönetemi ayarlamadı + " + Vergi" + Yeni oynatma listesi + Yeni oynatma listesine ekle + URL yönetimi + Grayjay\'in URL\'leri yönetmesine izin ver? + \'Evet\' e tıkladığınızda, Grayjay uygulama ayarları açılacak.\n\nOradan:\n1. "Varsayılan olarak aç" veya "Varsayılan olarak seç" bölümüne tıklayın.\n Bu seçeneği \'Gelişmiş\' bölümünün tam altında bulabilirsiniz, cihazınıza bağlı olarak.\n\n2. Grayjay için \'Desteklenen bağlantıları aç\' ı seçin.\n\n(bazı cihazlar bunu ana ayarlarda \'Varsayılan Uygulamalar\' ın altında listeledi, Grayjay için ilgili kategorileri seçin) + Ayarları gösterme başarısız oldu + Play store versiyonu varsayılan bağlantı yönetimine izin vermiyor. + Bu {commentCount} yorumun hepsi Grayjay\'de yaptığınız yorumların tamamı. + Öğretici + Yayınlamaya dön diyalog ekle + Nasıl yayınlama yapılacağına dair bir video izle + FCast teknik dokümantasyonu görüntüle + Rehber + FCast kullanım rehberi + FCast + FCast\'in web sitesini açın + FCast Website + FCast Teknik Dokümantasyon + Yorumlarınızı görüntülemek için giriş yapın + Polycentric devre dışı + Oynat Duraklat + Pozisyon + Öğreticiler + Bu öğreticileri görmek istiyor musunuz? Onlara her zaman daha fazla butonu ile ulaşabilirsiniz. + Üretici Ekle + Seç + Yakınlaştır + Güncelleme olup olmadığını kontrol et. + En yukarıya kaydırın + Pil Optimizasyonunu Devre Dışı Bırak + Pil optimizasyon ayarlarına gitmek için tıklayın. Pil optimizasyonunu devre dışı bırakmak işletim sisteminin medya otutumlarını durdurmasına engel olacaktır. + Kişisel Abonelikler Listesine Katkıda Bulun + \nŞu anki abonelikler listenizle FUTO\'ya katkıda bulunmak ister misiniz?\n\nVeri, Grayjay gizlilik politikasına bağlı olarak yönetilecektir. Bu liste anonimize edilecek ve aboneliklerin kime ait olduğu ile ilgili bir referans bulundurmadan saklanacaktır.\n\nBunun amacı ise Grayjay ve FUTO\'nun bu verileri kullanarak platformlar arası bir öneri sistemi oluşturarak Grayjay\'de seveceğiniz yeni içerik üreticilerini bulmayı kolaylaştırmak istemesi. + Yayın butonu + Incognito butonu + Üretici thumbnail + Aramayı temizle + Ara + Arama ikonu + Geri butonu + Uygulama ikonu + Geçmiş ikonu + Oynatma listesi oluştur + Paylaş + Filtrele + Sil + Ayarlar + Grup simgesi + Düzenle + İndir + Kapat + Duraklat + Oynat + Bağış miktarı + Yanıtlar + Beğen + Beğenme + Abone Ol + Platform göstergesi + Cihaz ikonu + Yükleyici + Bağış yazarının görüntüsü + Görüntüyü düzenle + Ekle + İndirme göstergeci + Çek ve bırak + Daha Sonra İzle\'ye ekle + Kapat + Sesini aç + Küçült + Döndürmeyi kilitle + Tekrarla + Önceki + Sonraki + Tam ekran + Otomatik Oynatma + Güncelleme döndürgeci + Oynat + Duraklat + Durdur + QR kodu okut + Yardım + Polycentric profil resmini değiştir + + Önerilenler + Abonelikler + + + 0.25 + 0.5 + 0.75 + 1.0 + 1.25 + 1.5 + 1.75 + 2.0 + 2.25 + + + Otomatik (720p) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + Hiç (Sadece Ses) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + 1 Thread + 2 Thread + 4 Thread + 6 Thread + 8 Thread + 10 Thread + 15 Thread + + + Asla + Her 15 Dakika + Her Saat + Her 3 Saat + Her 6 Saat + Her 12 Saat + Her Gün + + + Düşük Bitrate + Yüksek Bitrate + + + Uygulamayı Başlatırken + Asla + + + Devre Dışı + Etkin + + + Sınırsız İnternet + Wifi & Ethernet + Her Zaman + + + Devre Dışı + Etkin + + + En Yeni + En Eski + + + A\'dan Z\'ye + Z\'den A\'ya + Görüntüleme Artan + Görüntüleme Azalan + İzlenme Süresi Artan + İzlenme Süresi Azalan + + + Ad (A\'dan Z\'ye) + Ad (Z\'den A\'ya) + İndirme Tarihi (En Eski) + İndirme Tarihi (En Yeni) + Çıkış Tarihi (En Eski) + Çıkış Tarihi (En Yeni) + + + Önizle + Listele + + + Sistem + İngilizce (EN) + Almanca (DE) + İspanyolca (ES) + Portekizce (PT) + Fransızca (FR) + Japonca (JA) + Korece (KO) + Çince (ZH) + Rusça (RU) + Arapça (AR) + + + Hiç + Oynatmaya Devam Et + Oynatma Overlay\'i + + + Baştan Başla + 10s Sonra Başla + Her Zaman Devam Et + + + 24 + 30 + 60 + 120 + + + Polycentric + Platform + Son Seçilen + + + İngilizce + İspanyolca + Almanca + Fransızca + Japonca + Korece + Tayca + Vietnamca + Endonezce + Hintçe + Arapça + Türkçe + Rusça + Portekizce + Çince + + + FCast + ChromeCast + AirPlay + + + Hiç + Hata + Uyarı + Bilgi + Detaylı + + + Asla + 10 saniye içinde + 30 saniye içinde + Her Zaman + + + 15 + 30 + 45 + + + 100 + 500 + 750 + 1000 + 1500 + 2000 + + From b5d3261f0317b93fa02a17876f6e5f28ab59eaef Mon Sep 17 00:00:00 2001 From: 0xrxL <127248639+0xrxL@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:01:41 +0200 Subject: [PATCH 02/46] Add files via upload --- app/src/main/res/strings.xml | 1145 ++++++++++++++++++++++++++++++++++ 1 file changed, 1145 insertions(+) create mode 100644 app/src/main/res/strings.xml diff --git a/app/src/main/res/strings.xml b/app/src/main/res/strings.xml new file mode 100644 index 00000000..4b9cb7e0 --- /dev/null +++ b/app/src/main/res/strings.xml @@ -0,0 +1,1145 @@ + + Trasmetti + Cerca + Aggiungi alla lista + Anteprima + Immagine del canale + Aggiungi a + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Aggiungi alla coda + Aggiungi alla cronologia + Generali + Canale + Home + Barra di avanzamento + Impostazioni avanzate + Se le impostazioni avanzate sono visibili, verranno mostrate preferenze supplementari per affinare la tua esperienza d\'uso. + Se la barra di avanzamento cronologica deve essere mostrata + Applica 'Nascosto dalla home' anche alla ricerca' + Nascondi video e creatori nascosti dalla scheda home anche nei risultati di ricerca + Suggerimenti + Altro + Playlist + Sottoscrizioni + Caricamento + Riprova + Annulla + Recupero dei dati non riuscito, sei connesso ad una rete? + Impostazioni + Cronologia + Sorgenti + Acquista + FAQ + Modalità Riservatezza + La sorgente in alto sarà considerata come primaria + Predefiniti + Scheda Home + Qualità Preferita + Qualità di visualizzazione video predefinita + Aggiorna + Chiudi + Mai + Seleziona una delle seguenti opzioni di importazione disponibili. + C\'è un aggiornamento disponibile, desideri installarlo ora? + Download in corso… + Installazione in corso… + Finito + getHome + Riuscito + Aggiornamento pacchetto non riuscito, con errore + Operazione non riuscita per cause generiche + Operazione non riuscita per deliberato annullamento + Operazione non riuscita perché bloccata + Operazione non riuscita per conflitto (o inconsistenza) con un altro pacchetto già installato nel dispositivo + Operazione non riuscita per incompatibilità con il dispositivo + Operazione non riuscita per uno o più APK non validi + Operazionnon riuscita per problemi di archiviazione + LIVE + Live + Clicca per leggere altro + Clicca per nascondere + Versione + Ordina per + Iscriviti + Disiscriviti + Secondo piano + Video + Aggiungi + Download + Condividi + Mostra tutto + Creatori + Attivo + Mantieni schermo acceso + Mantieni schermo acceso in fase di trasmissione + Reindirizza sempre richieste + Reindirizza sempre le richieste attraverso il dispositivo durante la trasmissione dati. + Consenti IPV6 + Se la trasmissione IPV6 è consentita, potrebbero verificarsi problemi su alcune reti + Consenti Link Locale IPV4 + Se la trasmissione con link locale IPV4 è consentito, potrebbero verificarsi problemi su alcune reti + Scopri + Trova nuovi sorgenti video da aggiungere + Questi sorgenti sono stati disattivati + Questi sono i creatori visibili per questo gruppo. + Questi creatori non sono in questo gruppo. + Disattivato + Guarda più Tardi + Crea + OK + + No + Conferma + Non chiedere di nuovo + Sei sicuro di voler eliminare questa playlist? + Sei sicuro di voler eliminare questa sottoscrizione? + Rimuovere questa sorgente causerà la mancata risoluzione di alcune tue sottoscrizioni. + Casuale + Riproduci tutto + Cerca cronologia + Errore App + Sviluppatore + Rimuovi suggerimento cronologico + Commenti + La sezione commenti sottostante il lettore video + Merchandise + Raggiunta la fine della playlist + La playlist si riavvierà dopo la fine del video + Riavvia Ora + Raggiunta la fine della coda + Raggiunta la fine della playlist + Raggiunta la fine della lista Guarda più tardi + Raggiunta la fine della coda + La coda si riavvierà dopo la fine del video + Prossimo + Coda + Pulisci + Ultima ora + Ultime 24 ore + Ultima settimana + Ultimi 30 giorni + Ultimo anno + Tutto + Sei sicuro di voler rimuovere queste voci di cronologia? + rimosse + Aggiungi Sorgente + URL Piattaforma + URL Repository + URL Script + URL Configurazione + Questi sono i permessi necessari al plugin per funzionare + Il plugin avrà accesso a eval capacity + Il plugin avrà accesso ai seguenti domini + Scansiona QR + Scansiona un codice QR per effettuare l\'installazione + Inserisci un URL per caricare la configurazione del plugin. + Inserisci URL + Installa + Nessun dispositivo rilevato. Potrebbe volerci un pò per mostrare il tuo dispositivo, sii paziente + Non pronto + Connesso + Interrompi + Interrompi trasmissione + Inizia + Spazio di Archiviazione + Download + Deseleziona Tutto + Seleziona Tutto + Aggiungi Dispositivo di Trasmissione + Nuova attività + Il creatore si trova su NeoPass + Opzioni + Esporta + Elimina + Caricamento aggiornamento… + Quando navighi tra i video online e desideri lasciare un commento, prendi in considerazione Polycentric, un nuovo modo di gestire la tua presenza online che è sia facile da usare che incentrato sulle tue esigenze. Ecco perché creare un profilo Polycentric è un\'ottima scelta:\n\n 1. Hai il controllo: Con Polycentric, i tuoi dati non sono archiviati in un unico luogo controllato da una singola azienda. Invece, sono distribuiti su più posizioni, offrendoti un maggiore controllo sulla tua presenza online. Decidi tu dove vengono archiviati i tuoi dati e puoi facilmente passare da un fornitore all\'altro o aggiungerne di nuovi.\n\n 2. Privacy e sicurezza: Polycentric mantiene le tue informazioni al sicuro utilizzando tecniche di sicurezza avanzate. Puoi star certo che i tuoi dati personali sono ben protetti e accessibili solo a coloro con cui scegli di condividerli.\n\n 3. Networking senza interruzioni: Polycentric ti consente di connetterti con altri e interagire con i loro contenuti senza essere legato a un\'unica piattaforma. Se un fornitore diventa inaffidabile o tenta di bloccare l\'accesso, il tuo client Polycentric troverà automaticamente le informazioni da altre fonti, mantenendoti connesso.\n\n 4. Ricerca e suggerimenti intelligenti: Polycentric utilizza più fonti per la ricerca ed i suggerimenti, offrendoti prestazioni di prim\'ordine e garantendo che nessun singolo fornitore abbia eccessiva influenza sui risultati che vedi.\n\n 5. Adattabile e aperto: Polycentric è progettato per essere flessibile e aperto a nuovi sviluppi, consentendo miglioramenti costanti e nuove funzionalità che si adattano alle esigenze dei suoi utenti.\n\n 6. Creando un profilo Polycentric, puoi lasciare commenti sui video online e goderti un\'esperienza più personalizzata. Scopri tu stesso come questa soluzione intuitiva ti mette al centro della tua vita digitale. Iscriviti oggi stesso a Polycentric! + Pubblica + Uso + Attiva in home + Attiva in ricerca + Verifica + Grayjay non è un\'app facile o economica da costruire e mantenere. Abbiamo ingegneri a tempo pieno che lavorano sull\'app e sui suoi sistemi correlati. E probabilmente ciò non aiuterà a rientrare nei costi di sviluppo in tempi brevi, se mai ciò dovesse accadere.\n\nLa missione di FUTO è che il software open-source e le pratiche commerciali software non dannose diventino una fonte di reddito sostenibile per i progetti e i loro sviluppatori. Per questo motivo il team GrayJay si prefissa come obiettivo quello di far pagare agli utenti finali il proprio software. + È stata lanciata un\'eccezione non gestita, siamo dispiaciuti per l\`inconveniente. + Per aiutarci a risolverla, ti chiediamo di inviarci la seguente segnalazione crash, aggiungendo eventuali informazioni sul contesto nel quale si è verificata. + Nuovo Profilo + Importa Profilo Esistente + Genera una nuova identità + Utilizza un\'identità esistente + Permessi + Avvisi di Sicurezza + Questi sono avvisi sui comportamenti e l\'implementazione del plugin + Inserisci il captcha e chiudi una volta terminato + CHIUDI + Invia + Riavvia + Gestisci Schede + Scansiona per importare + Invia la tua identità ad un\'altra app + Copia + Copia la tua identità negli appunti + Polycentric + Nome Profilo + Questo sarà visibile ad altri utenti + Crea Profilo + O + Incolla profilo qui polycentric://… + Importa Profilo + Apparentemente sei uno sviluppatore + Impostazioni Sviluppatore + Migrazione + Imposta una password per il tuo backup giornaliero + Imposta una password per crittografare il tuo backup giornaliero scritto su memoria esterna. + Password Backup + Ripeti Password + Ripristina da Backup Automatico + Sembra che un backup automatico esista già sul tuo dispositivo, se intendi ripristinarlo, inserisci la password del tuo backup. + Ripristina + Messaggio di errore qui + Nome + IP + Porta + Dispositivi Rilevati + Dispositivi disponibili + Dispositivi Salvati + Non riesci a trovare il dispositivo che stai cercando? Prova ad aggiungerlo manualmente. + Non ci sono dispositivi salvati + Connesso a + Volume + Lista cambiamenti + Mostra le liste dei cambiamenti disponibili per la versioni corrente e passate + Qualche esempio di lista cambiamenti. + Precedente + Successivo + Commento + Aggiungi manualmente + Il commento non è vuoto, vuoi procedere comunque alla chiusura? + Importa + Nome della Mia Playlist + Vuoi importare questo negozio? + La sorgente richiesta va attivata + Gli elementi richiedono la migrazione o sono corrotti, vuoi procedere al loro ripristino da backup? + Se ignorato ti verrà chiesto nuovamente al prossimo avvio. + Ignora + Mostra lista cambiamenti + Ho Già Pagato + Membership + Un pagamento ricorrente con frequenza mensile + vantaggi aggiuntivi + Un pagamento una tantum per supportare il creatore + Un negozio di questo creatore + Donazione + Promozioni + Promozioni correnti da questo creatore + Download + Video + Pulisci cronologia + Nulla da importare + Non hai sorgenti intallate, aggiungine alcune se vuoi utilizzare l\'app nei modi previsti. + Attivare molte sorgenti può ridurre la velocità di caricamento della tua applicazione. + Supporto + Membership + Store + Live Chat + Rimuovi + Video + Playlist + Canale Polycentric + Attiva + In Costruzione + IN + COSTRUZIONE + Disattiva + Il seguente creatore non può essere visualizzato su Grayjay a causa del plugin mancante. + Questo contenuto è bloccato + Sconosciuto + Tocca per aprire nel browser + Plugin Mancante + Gli spettatori stanno accedendo + Vai ora + Previeni + Playlist Usate di Recente + Licenza email + Richiesta per l\'invio della chiave di licenza + Email di ricezione (utente@dominio.it) + Pagamento utilizzato + Paese + Codice di Avviamento Postale + Grayjay + Imposta sulle Vendite ( + Totale + Paga + Metodi di Pagamento Standard + Stripe è un servizio di pagamento online sicuro che accetta le principali carte di credito, di debito, e vari metodi di pagamento localizzati. + Aggiungi un commento… + Chiudi + Scansiona un codice QR per installare + Attiva/Disattiva schermo intero + Dal + Firma + Valido + Ripeti + Vedi + Installa con QR + Installa un plugin scansionando un codice QR + Disconnetti + Video + Guarda un video più approfondito + Documentazione Tecnica + Visualizza la documentazione tecnica + Visita il mio negozio + Crea un backup della tua identità + Disconnettiti da questa identità + Elimina questo profilo + Installa con URL + Memorizza i video precedentemente visti per caricarli più velocemente + Una lista di problemi segnalati dall\'utente e segnalati in automatico + Rimuovi anche qualsiasi dato relativo al plugin come accesso o impostazioni + Annuncu + Notifiche + Verifica aggiornamenti plugin disattivati + Verifica gli aggiornamenti per i plugin disattivati + Notifiche Contenuto Pianificato + Programma il contenuto pianificato scoperto sotto forma di notifiche, più accurate per tale contenuto. + Tenta di utilizzare intervallo in byte + Aggiornamenti Automatici + Consenti sempre la rotazione automatica inversa orizzontale + La rotazione automatica sarà sempre presente tra i due orientamenti orizzontali in modalità a schermo intero, anche quando disattivi la rotazione automatica nelle impostazioni di sistema. + Semplifica sorgenti + Deduplica i sorgenti in base alla loro risoluzione in modo che siano visibili solo quelli più pertinenti. + Backup Automatico + Comportamento in Secondo Piano + Aggiornamento in Secondo Piano + Download in Secondo Piano + Backup + Navigazione + Concorrenza ByteRange + Download ByteRange + Trasmissione + Modifica il comportamento del lettore + Modifica il percorso esterno dei Download + Cancella il percorso esterno dei Download + Modifica il percorso esterno generale + Modifica le schede visibili nella scheda home + Gestione Link + Consenti a Grayjay di gestire i link + Modifica la directory esterna per i file generali + Cancella l\'archivio esterno per i file di download + Modifica l\'archivio esterno per i file di download + Cancella Cookie + Cancella Cookie Dopo Disconnessione + Test Worker in Secondo Piano + + Cancella Pagamento + Cancella i cookie quando effettui la disconnessione + Cancella i cookie del browser in-app + Configura il comportamento di navigazione + Barra minutaggio + Configura se la barra del minutaggio cronologica deve essere mostrate + Configurazione trasmissione + Configura il backup giornaliero in caso di guasto catastrofico + Configura il download dei video + Configurazione funzionale ed estetica della scheda Home + Configurazione funzionale ed estetica delle tue sottoscrizioni + Configura se il download in secondo piano deve essere utilizzato + Configura l\'aggiornamento automatico + Configura quando gli aggiornamenti devono essere scaricati + Configura quando i video devono essere scaricati, se solo su reti non a consumo (Wi-Fi) + Crea un file zip con i tuoi dati che può essere importato su Grayjay + Qualità Audio Predefinita + Velocità di Riproduzione Predefinita + Qualità Video Predefinita + Elimina le chiavi di licenza dall\'app + Scarica quando + Attiva Memorizzazione Video + Attiva trasmissione + Aggiornamento in secondo piano sperimentale per la memorizzazione delle sottoscrizioni + Esporta Dati + Importa Dati + Seleziona un file da importare, supporta vari tipi di file (alternativa all\'apertura diretta) + Archivio Esterno + Stile Feed + Lingua + Lingua App + Potrebbe richiedere il riavvio + Recupera all\'avvio dell\'app + Recupera all\'apertura della scheda + Recupera nuovi risultati all\'apertura della scheda (se non ci sono ancora risultati, la disattivazione non è consigliata a meno che tu non abbia problemi) + Ricarica sempre dalla memoria + Non è consigliata, ma può rappresentare una soluzione per alcuni problemi. + Visualizza Contenuti Canale + Visualizza i contenuti del canale se supportato dal plugin delle chiamate a tasso limitato (potrebbe incrementare il tempo di ricarica della sottoscrizione). + Ottieni risposte alle domande comuni + Fornisci feedback sull\'applicazione + Info + Networking + Sincronizzazione + Attiva funzionalità + Broadcast mDNS + Consenti al dispositivo di trasmettere la propria presenza usando mDNS + Connessione mDNS + Consenti al dispositivo di cercare e avviare una connessione con dispositivi associati noti utilizzando mDNS + Connetti Ultimo Conosciuto + Consenti al dispositivo di connettersi automaticamente agli ultimi endpoint conosciuti + Attiva Relay + Consenti al dispositivo di utilizzare un relay per la scoperta/connessione ad un relay + Associa Relay + Consenti al dispositivo di essere associato tramite relay + Connessione Relay + Consenti al dispositivo di connettersi utilizzando un relay + Connessione Diretta Relay + Consenti al dispositivo di connettersi in maniera diretta utilizzando le informazioni pubblicate dal relay + Associa Listener + Consenti al dispositivo di connettersi direttamente + Controlli gestuali + Cursore volume + Attiva il gesto di scorrimento per modificare il volume + Cursore luminosità + Attiva il gesto di scorrimento per modificare la luminosità + Attiva/Disattiva schermo intero + Attiva il gesto di scorrimento per entrare/uscire dalla modalità a schermo intero + Luminosità di sistema + I controlli gestuali possono regolare la luminosità di sistema + Ripristina luminosità di sistema + Ripristina la luminosità di sistema all\'uscita dalla modalità a schermo intero + Attiva zoom + Attiva il gesto di zoom a due dita + Attiva pan + Attiva il gesto di pan a due dita + Volume di sistema + I controlli gestuali regolano il volume di sistema + Live Chat Webview + Schermo intero verticale + Consenti verticale inversa + Consenti all\'app di capovolgersi in modalità verticale inversa + Zona di rotazione + Specifica la sensibilità delle zone di rotazione (diminuiscila per renderla meno sensibile) + Soglia temporale di stabilità + Specifica per quanto tempo l\'orientamento deve rimanere invariato prima che una rotazione si attivi + Prediligi Codec Video Webm + Se il lettore deve utilizzare i codec Webm (vp9/opus) anziché mp4 (h264/AAC). Potrebbe causare una peggiore compatibilità. + Blocco rotazione automatica completa + Impedisce qualsiasi rotazione mentre il blocco di rotazione è attivo (anche il passaggio tra orizzontale e orizzontale inverso). + Prediligi Codec Audio Webm + Se il lettore deve utilizzare i codec Webm (opus) anziché mp4 (AAC). Potrebbe causare una peggiore compatibilità. + Consenti video oltre ritaglio + Consenti al video di superare il ritaglio dello schermo sottostante in modalità a schermo intero.\nPotrebbe richiedere un riavvio + Riproduci automaticamente il video successivo per impostazione predefinita + La riproduzione automatica del video successivo sarà attiva per impostazione predefinita ogni volta che guarderai un video + Consenti modalità verticale a schermo intero quando si guardano video orizzontali + Elimina da Guarda più tardi dopo visione + Dopo aver chiuso un video che hai guardato in larga parte, questo verrà rimosso da 'Guarda più tardi'. + Durata ricerca + Velocità di riproduzione minima + Velocità minima disponibile + Velocità di riproduzione massima + Velocità massima disponibile + Mantieni velocità di riproduzione + Velocità di riproduzione quando si effettua una pressione prolungata sul lettore video + AmpiezzaVelocità Avanzamento Riproduzione + La velocità di avanzamento della riproduzione. Potrebbe non influenzare le velocità di riproduzione più elevate. + Durata di avanzamento/riavvolgimento rapido + Passa all\'audio in secondo piano + Gruppi + Mostra gruppi di sottoscrizione + Utilizza Scambio Sottoscrizioni (Sperimentale) + Utilizza un server centralizzato crowd-sourced per ridurre significativamente le richieste necessarie per le sottoscrizioni. In cambio verranno inviate le tue sottoscrizioni al server. + Se i gruppi di sottoscrizione devono essere mostrati al di sopra delle tue sottoscrizioni da filtrare + Anteprima Elementi Feed + Quando viene applicato lo stile anteprima per i feed, decide se gli elementi devono essere automaticamente visualizzati in anteprima quando si scorre su di essi + Mostra Filtri Home + Se i filtri nella scheda home devono essere mostrati al di sopra di quest\'ultima + Nomi Plugin Filtri Home + Se i filtri nella scheda home devono mostrare i nomi completi dei plugin o solo le icone + Livello di Log + Logging + Stato Licenza + Visualizza stato licenza + Sincronizza Grayjay + Sincronizza i tuoi dati su più dispositivi + Gestisci identità Polycentric + Gestisci la tua identità Polycentric + Verifica manuale + Verifica manualmente gli aggiornamenti + Numero di thread concorrenti per moltiplicare le velocità di download da fonti limitate + Pagamento + Stato Pagamento + Aggira Prevenzione Rotazione + Conferma Eliminazione Playlist + Mostra la finestra di dialogo di conferma quando si elimina un media da una playlist + Consenti video duplicati nella playlist + Consenti l\'aggiunta di video duplicati alle playlist + Attiva Polycentric + Attiva Memorizzazione Locale Polycentric + Memorizza i risultati Polycentric sul dispositivo per ridurre i tempi di caricamento, la modifica richiede il riavvio dell\'app + Può essere disattivato quando si riscontrano problemi + Consente la rotazione quando si visualizza un media non-video.\nATTENZIONE: Non è progettato per questo + Ciò potrebbe causare un comportamento inaspettato ed è per lo più non testato. + La modifica di questo campo richiederà il riavvio dell\'app. + Lettore + Plugin + Qualità di Trasmissione Preferita + Qualità predefinita durante la trasmissione su dispositivo esterno + Qualità Preferita con Dati a Consumo + Qualità predefinita su connessioni a consumo come la rete cellulare + Qualità Anteprima Preferita + Qualità predefinita durante l\'anteprima di un video in un feed + Lingua Principale + Prediligi Audio Originale + Utilizza l\'audio originale anziché la lingua preferita quando possibile + Sezione Commenti Predefinita + Nascondi Suggerimenti + Nascondi completamente la scheda dei suggerimenti. + Suggerimenti Come Predefiniti + Mostra i suggerimenti come elemento predefinito, anziché i commenti. + Scolora Commenti con Cattiva Reputazione + Se il colore dei commenti con una reputazione molto negativa deve essere ridotto. La disattivazione di questa opzione potrebbe peggiorare l\'esperienza d\'uso. + Reinstalla Plugin Integrati + Rimuovi Versione Memorizzata + Rimuovi l\'ultima versione scaricata + Cancella Nascosti + Rimuove tutti i creatori e i video nascosti, mostrandoli nuovamente + Reimposta annunci + Reimposta annunci nascosti + Ripristina Backup Automatico + Ripristina un precedente backup automatico + Riprendi dopo Anteprima + Visiona nuovamente le liste dei cambiamenti attuali e passate + Riavvia dopo perdita di focus audio + Riavvia la riproduzione quando si riacquista il focus audio dopo una perdita + Riavvia dopo perdita di connettività + Riavvia la riproduzione quando si riacquista la connettività dopo una perdita + FPS Aggiornamento Capitoli + Modifica la precisione dell\'aggiornamento dei capitoli, un valore più alto potrebbe richiedere maggiori risorse + Imposta Backup Automatico + Poco dopo aver aperto l\'app, inizia a recuperare le sottosscrizioni + Mostra FAQ + Mostra Problemi + Specifica quanti thread vengono utilizzati per recuperare i canali + Invia feedback + Invia log + Cancella Memoria Canale + Elimina tutti i contenuti dalla memoria riservata ai canali di sottoscrizione + Invia i tuoi log per aiutarci ad isolare i problemi + Concorrenza Sottoscrizioni + Traccia Tempo di Riproduzione Localmente + Traccia localmente il tempo di riproduzione delle sottoscrizioni, utilizzato per il riordinamento di queste ultime e dei suggerimenti locali dei creatori. + Mostra Metriche di Visione + Mostra il tempo di visione e le visualizzazioni di ogni creatore nella scheda dedicata + Questo impedisce al dispositivo di ruotare entro la quantità di gradi specificata + Utilizza la finestra web della live chat quando disponibile anziché l\'implementazione nativa + Codice Versione + Nome Versione + Tipo Versione + Dimensione Memoria Canale (Avvio) + Quando si guarda un video in modalità anteprima, riprendi la riproduzione da questa posizione dopo l\'apertura del video + Attiva il logging per inviare i log + Plugin integrati reinstallati, si consiglia un riavvio + Annunci ripristinati. + Apertura negozio non riuscita. + Recupero lista dei cambiamenti + Pagato + Non Pagato + Licenze cancellate, potrebbe richiedere il riavvio dell\'app + Tenta di recuperare 2 pagine da getHome + Test Sottoscrizioni in Secondo Piano + Cancella Tutti i Download + Cancella Download + Cancella tutti i cookie dal Gestore Cookie + Fai Crashare + Fa crashare l\'applicazione di proposito + Elimina Annunci + Elimina Irrisolti + Elimina tutti gli annunci + Elimina tutti i video scaricati e i file correlati + Elimina tutti i download in corso + Elimina tutti i file sorgente irrisolti + Modalità Sviluppatore + Consenti Tutti i Certificati + Ciò rischia di esporre tutto il tuo traffico di rete Grayjay. + Server di Sviluppo + Sperimentale + Memoria + Riempi memoria di archiviazione fino a generare un errore + Inietta + Inietta una configurazione test di origine (locale) in V8 + Altro + Altri… + Rimuove tutte le sottoscrizioni + Impostazioni relative al server di sviluppo, fai attenzione poiché potrebbero esporre il tuo telefono a vulnerabilità di sicurezza + Avvia Server + Test Riproduzione + Continua a riprodurre video + Memoria Sottoscrizioni 5000 + Memoria Cronologia 100 + Esegui Server all\'avvio + Esegui un DevServer sulla porta 11337. Ciò potrebbe esporre a vulnerabilità. + Test Velocità Comunicazione V8 + Test Velocità Creazione V8 + Testa le velocità di comunicazione V8 + Testa i tempi di creazione e funzionamento di V8 + Disiscriviti da tutti + Benchmark V8 + Test Script V8 + Vari benchmark utilizzando il motore V8 integrato + Esegui vari test contro una fonte personalizzata + Scrive su disco fino a quando non c\'è più spazio + Visibilità + Verifica aggiornamenti + Se gli aggiornamenti di un plugin devono essere verificati all\'avvio + Aggiornamento Automatico + Aggiorna automaticamente all\'avvio se non sono cambiate le autorizzazioni e il plugin è attivo + Consenti Invio Sviluppatore + Consente allo sviluppatore di inviare dati al proprio server, fai attenzione poiché questo potrebbe esporre i tuoi dati sensibili. + Assicurati di fidarti dello sviluppatore. Potrebbe ottenere l\'accesso a dati sensibili. Attiva questa opzione solo quando stai cercando di aiutare lo sviluppatore a risolvere un bug. + Tasso di frequenza limite + Impostazioni relative al limite del tasso di frequenza di questo plugin + Tasso di frequenza limite Sottoscrizioni + Limita la quantità di richieste di sottoscrizione effettuate + Attiva dove i contenuti di questo plugin sono visibili + Mostra contenuti nella scheda home + Mostra contenuti nei risultati di ricerca + Nessun URL valido fornito... + Formato Configurazione Non Valido + Recupero configurazione non riuscito + Recupero script non riuscito + Accesso URL + Il plugin avrà accesso ai seguenti domini + Accesso Eval + Il plugin avrà accesso alla funzionalità di valutazione (iniezione remota) + Scansionamento codice QR non riuscito + Non è un URL di plugin + Scansiona un codice QR + Non ancora implementato.. + Contesto Sconosciuto + Qualcosa è andato storto… traccia dello stack mancante? + Log già inviati. + Nessun log trovato. + Condivisione automatica non riuscita, passare alla modalità manuale? + Condiviso {id} + Eccezione non gestita in VS + Invio eccezione agli sviluppatori… + La tua chiave di licenza è stata impostata!\nIl riavvio dell\'app potrebbe essere richiesto. + Formato licenza non valido + Formato contenuto sconosciuto + Formato file sconosciuto + Formato Polycentric sconosciuto + Formato url sconosciuto + Gestione file non riuscita + Tipo di ricostruzione sconosciuto +Analisi file di testo non riuscita + Analisi delle sottoscrizioni di NewPipe non riuscita + Generazione codice QR non riuscita + Condividi testo + Testo copiato + Deve contenere almeno 3 caratteri. + Creazione del profilo non riuscita. + Completo riempimento dei server non riuscito. + Accedi a questa identità + Il campo di testo non contiene dati + URL non valido + Questo profilo è già stato importato + Importazione profilo non riuscita: + Riempimento client non riuscito + Sei sicuro di voler rimuovere questo profilo? + Nessun handle di processo impostato + Il nome deve contenere almeno 3 caratteri + Handle di processo non impostato + Lettura immagine non riuscita + Le modifiche sono state salvate + Sincronizzazione modifiche non riuscita + Selezione immagine annullata + Ora sei in modalità sviluppatore + Iscritti + Trova {name} su + Recupero non riuscito + Grazie per l\'acquisto, una chiave verrà inviata alla tua email una volta ricevuto il pagamento! + Pagamento non riuscito + Pagamento riuscito + Inserisci la chiave di licenza + Chiave di licenza non valida + Licenza + Attivazione chiave non riuscita + Nessuna fonte attiva per supportare questo canale + Vuoi convertire il canale {channelName} in una playlist? + Conversione canale non riuscita + Pagina + Sincronizza video + Nascondi + Nascondi dalla scheda Home + Nascondi il creatore dalla Home + Riproduci il feed come coda + Riproduci l\'intero feed + In coda + Già in coda + Utilizzato + Disponibile + Caricamento della pagina successiva non riuscito + Errore plugin {pluginName}:\n{message} + Errore plugin causato da: + Ottenimento plugin non riuscito\nHome + Ottenimento della Home non riuscito + Nessuna home disponibile + Nessuna home page è disponibile, verifica la connessione ad internet e ricarica. + Importa playlist + playlist importate. + {index} su {size} selezionati + Importa sottoscrizioni + sottoscrizioni importate. + Modifica playlist + Condividi come testo + Condividi come lista di URL video + Condividi come importazione + Condividi come file di importazione per Grayjay + Caricamento playlist non riuscito + Attendi il caricamento della playlist + Playlist copiata come playlist locale + Sei sicuro di voler eliminare i video scaricati? + Crea nuova playlist + Crea nuovo gruppo + Contenuto multimediale atteso, trovato + Caricamento post non riuscito. + risposte + Risposte + Capitoli + Impostazioni plugin salvate + Impostazioni plugin + Queste impostazioni sono definite dal plugin + Caricamento sorgente non riuscito + Verifica aggiornamenti + Verifica l\'esistenza di nuove versioni della sorgente + Autenticazione + Esci dalla piattaforma + Importa i tuoi abbonamenti da questa sorgente + Importa le tue playlist da questa sorgente + Accedi + Accesso richiesto + Accedi alla piattaforma di questa sorgente + Gestione + Disinstalla + Rimuove il plugin dall\'app + Elimina Captcha + Elimina la risposta captcha memorizzata per questo plugin + Recupero playlist non riuscito. + {subscriptionCount} abbonamenti utente recuperati. + Recupero abbonamenti non riuscito. + Sei sicuro di voler procedere alla disinstallazione + Disinstallato + Verifica degli aggiornamenti non riuscita + Il plugin è completamente aggiornato + Avviso limite tasso di frequenza + Questa è una misura temporanea per impedire alle persone di superare il limite del tasso di frequenza finché non avremo un supporto migliore per molti abbonamenti. + \n\nHai troppi abbonamenti per i seguenti plugin:\n + Post + Pianificato + Guardato + Nessun risultato trovato\nTrascina verso il basso per ricaricare + Sovrapposizione + Ricarica + in visione ora + visualizzazioni + Pianificato in + Ottenimento Tracciatore Riproduzione non riuscito + Eccezione durante il recupero degli eventi live: + Eccezione durante il recupero della finestra live chat: + Caricamento live chat non riuscito + Formato Trasmissione non supportato + Caricamento media non riuscito + Riproduzione offline + Errore media + La sorgente multimediale ha riscontrato un errore di mancata autorizzazione.\nQuesto potrebbe essere risolto ricaricando il plugin.\nVuoi ricaricarlo?\n(Sperimentale) + Velocità di riproduzione + Qualità + Video offline + Audio offline + Sottotitoli offline + Streaming video + Streaming audio + Audio + Sottotitoli + Video non disponibile + Questo video non è disponibile. + C'era un video non disponibile nella tua coda: [{videoName}] di [{authorName}]. + Indietro + Pausa + Riproduci + Mette in pausa il video + Riprende il video + Video senza sorgente + C\'era un video nella tua coda: [{videoName}] di [{authorName}], privo di una sorgente attiva richiesta, quindi la sua riproduzione è stata saltata. + Caricamento video non riuscito (ScriptImplementationException) + Video non valido + C\'era un video non valido nella tua coda: [{videoName}] di [{authorName}], quindi la sua riproduzione è stata saltata. + Video con restrizioni di età + C\'era un video con restrizioni di età nella tua coda: [{videoName}] di [{authorName}], questo video non era accessibile e la sua riproduzione è stata saltata. + Caricamento video non riuscito (ScriptException) + Caricamento video non riuscito + Non ancora disponibile, nuovo tentativo tra {time}s + Tentativo per lo streaming live non riuscito + Questa app è ancora in fase di sviluppo. Ti suggeriamo di inviare segnalazioni in caso di errori e di comprendere che molte funzionalità risultato essere incomplete. + Utilizza almeno 1 carattere + Sei sicuro di voler eliminare questo video? + Tocca per aprire + Aggiornamento disponibile! + in visione + disponibile in + secondi + Accedi per pubblicare un commento + Pubblicazione del commento non riuscita: + In attesa di una connessione non a consumo + Ultimo errore + Errore + Filtri + spettatori + È prevista la ricezione di almeno una risposta, ma non ne è stata restituita alcuna dal server + Accedi per mettere mi piace + Accedi per poter aggiungere i dislike + Caricamento commenti non riuscito. + Script non disponibile + Firma valida + Firma non valida + Nessuna firma disponibile + Iscritto a: + Disiscritto da: + Non hai backup automatici + È disponibile un vecchio backup + Vuoi ripristinare questo backup? + Sovrascrivi + Riprova dati + Nessun download disponibile + Nessuna sorgente (ancora) scaricabile + Nessuna + Solo audio + Scarica video + Recupero dettagli video + Impossibile recuperare i dettagli per il download + Risoluzione di destinazione + Bitrate di destinazione + Bitrate basso + Bitrate alto + Azioni + Scarica il video + Opzioni video + Cambia pin + Decidi quali pulsanti devono essere bloccati + Seleziona i tuoi elementi fissati in ordine + Altre opzioni + Salva + Questo creatore non ha impostato alcuna opzione di supporto su Harbor (Polycentric) + Carica altro + Fermato dopo {requestCount} richieste per evitare di superare limite del tasso di frequenza, clicca su carica altro per caricare altre informazioni. + Questo creatore non ha impostato alcuna funzionalità di monetizzazione + " + Tasse" + Nuova playlist + Aggiungi a nuova playlist + Gestione URL + Consentire a Grayjay di gestire URL specifici? + Quando fai clic su "Sì", si apriranno le impostazioni dell\'app Grayjay.\n\nDa lì, accedi a:\n1. Sezione 'Apri per impostazione predefinita' o 'Imposta come predefinito'.\nPotresti trovare questa opzione direttamente oppure nella categoria 'Impostazioni Avanzate', a seconda del tuo dispositivo.\n\n2. Seleziona 'Apri link supportati' per Grayjay.\n\n(alcuni dispositivi hanno questa opzione elencata nella finestra 'App predefinite' delle impostazioni di sistema, seguita dalla possibilità di selezionare Grayjay per le categorie pertinenti) + Apertura impostazioni non riuscita + La versione del Play Store non supporta la gestione URL predefinita. + Questi sono tutti i {commentCount} commenti che hai postato con Grayjay. + Tutorial + Torna alla finestra di dialogo per l\'aggiunta di una sessione di trasmissione + Guarda un video su come trasmettere + Visualizza la documentazione tecnica di FCast + Guida + Guida all\'utilizzo di FCast + FCast + Apri il sito web di FCast + Sito web di FCast + Documentazione tecnica di FCast + Accedi per visualizzare i tuoi commenti + Polycentric è disattivato + Riproduci/Pausa + Posizione + Tutorial + Vuoi guardare i tutorial? Puoi accedervi in qualsiasi momento cliccando sul pulsante 'Altro'. + Aggiungi creatori + Seleziona + Zoom + Verifica la disponibilità di aggiornamenti. + Scorri in alto + Disattiva ottimizzazione batteria + Clicca per accedere alle impostazioni di ottimizzazione della batteria. Disattivare l\'ottimizzazione della batteria impedirà al sistema operativo di terminare le sessioni multimediali. + Contribuisci con l\'elenco delle sottoscrizioni personali + \nTi piacerebbe contribuire a FUTO con la tua attuale lista di sottoscrizioni ai creatori?\n\nI dati saranno gestiti in conformità con la politica sulla privacy di Grayjay. Ovvero, la lista sarà anonimizzata e archiviata senza alcun riferimento a chiunque appartenga l\'elenco dei creatori.\n\nL\'intenzione è che Grayjay e FUTO utilizzino questi dati per costruire un sistema di suggerimenti dei creatori multipiattaforma per rendere più facile trovarne di nuovi che potrebbero piacerti direttamente all\'interno di Grayjay. + Pulsante trasmetti + Pulsante incognito + Miniatura creatore + Pulisci cerca + Cerca + Icona cerca + Pulsante indietro + Icona app + Icona cronologia + Crea playlist + Condividi + Filtra + Elimina + Impostazioni + Immagine del gruppo + Modifica + Scarica + Chiudi + Pausa + Riproduci + Ammontare donazione + Repliche + Like + Dislike + Iscriviti + Indicatore piattaforma + Icona dispositivo + Caricatore + Immagine autore della donazione + Modifica immagine + Aggiungi + Indicatore Scaricamento + Trascina e rilascia + Aggiungi a Guarda Più Tardi + Chiudi + Smuta + Minimizza + Blocca rotazione + Ripeti + Precedente + Prossimo + Schermo intero + Riproduzione automatica + Aggiorna caricamento + Riproduci + Pausa + Ferma + Scansiona codice QR + Aiuto + Modifica immagine di profilo Polycentric + + Suggerimenti + Sottoscrizioni + + + 0.25 + 0.5 + 0.75 + 1.0 + 1.25 + 1.5 + 1.75 + 2.0 + 2.25 + + + Automatico (720p) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + Nessuno (Solo Audio) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + 1 Threads + 2 Threads + 4 Threads + 6 Threads + 8 Threads + 10 Threads + 15 Threads + + + Mai + Ogni 15 Minuti + Ogni Ora + Ogni 3 Ore + Ogni 6 Ore + Ogni 12 Ore + Ogni Giorno + + + Basso Bitrate + Alto Bitrate + + + All\'Avvio + Mai + + + Disattivato + Attivo + + + Illimitato + Su Wi-Fi + Sempre + + + Disattivato + Attivo + + + Più Recente + Più Vecchio + + + Nome Crescente + Nome Discendente + Visualizzazioni Crescenti + Visualizzazioni Discendenti + Tempo di Visione Crescente + Tempo di Visione Discendente + + + Nome (Crescente) + Nome (Discendente) + Data di Scaricament (Più Vecchia) + Data di Scaricamento (Più Recente) + Data di Rilascio (Più Vecchia) + Data di Rilascio (Più Recente) + Dimensione (Più Piccola) + Dimensione (Più Grande) + + + Nome (Ascending) + Nome (Descending) + Data di Modifica Date (Più Vecchia) + Data di Modifica (Più Recente) + Data di Creazione (Più Vecchia) + Data di Creazione (Più Recente) + Data di Visione (Più Vecchia) + Data di Visione (Più Recente) + + + Anteprima + Lista + + + Sistema + Inglese (EN) + Tedesco (DE) + Italiano (IT) + Spagnolo (ES) + Portoghese (PT) + Francese (FR) + Giapponese (JA) + Coreano (KO) + Cinese (ZH) + Russo (RU) + Arabo (AR) + + + Nessuno + Continua a Riprodurre + Sovrapponi Riproduttore + + + Ricomincia dall\'Inizio + Riprendi Dopo 10s + Riprendi Sempre + + + 24 + 30 + 60 + 120 + + + Polycentric + Piattaforma + Ultima selezione + + + Inglese + Spagnolo + Tedesco + Francese + Giapponese + Coreano + Thailandese + Vietnamita + Indonesiano + Indiano + Arabo + Turco + Russo + Portoghese + Cinese + + + FCast + ChromeCast + AirPlay + + + Nessuno + Errore + Avviso + Informazione + Verboso + + + Mai + Entro 10 secondi dalla perdita + Entro 30 secondi dalla perdita + Sempre + + + 3 secondi + 5 secondi + 10 secondi + 20 secondi + 30 secondi + 60 secondi + + + 2.0 + 2.25 + 3.0 + 4.0 + 5.0 + + + 1.25 + 1.5 + 1.75 + 2.0 + 2.25 + 2.5 + 2.75 + 3.0 + + + 0.25 + 0.5 + 1.0 + + + 0.05 + 0.1 + 0.25 + + + 15 + 30 + 45 + + + 100 + 500 + 750 + 1000 + 1500 + 2000 + + From bb066a7a310de5cd081f3816a7cae7f6f04b0663 Mon Sep 17 00:00:00 2001 From: 0xrxL <127248639+0xrxL@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:02:25 +0200 Subject: [PATCH 03/46] Added italian localization --- app/src/main/res/{ => values-it}/strings.xml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/res/{ => values-it}/strings.xml (100%) diff --git a/app/src/main/res/strings.xml b/app/src/main/res/values-it/strings.xml similarity index 100% rename from app/src/main/res/strings.xml rename to app/src/main/res/values-it/strings.xml From 98c637814840bea5a80e87b97f26fbacb2b5cbb1 Mon Sep 17 00:00:00 2001 From: 0xrxL <127248639+0xrxL@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:04:40 +0200 Subject: [PATCH 04/46] Fix spacing --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4b9cb7e0..14a81c13 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -626,7 +626,7 @@ Formato url sconosciuto Gestione file non riuscita Tipo di ricostruzione sconosciuto -Analisi file di testo non riuscita + Analisi file di testo non riuscita Analisi delle sottoscrizioni di NewPipe non riuscita Generazione codice QR non riuscita Condividi testo From 4eb20a184347c7e3ae1e0f51a767fa415f086e06 Mon Sep 17 00:00:00 2001 From: 0xrxL <127248639+0xrxL@users.noreply.github.com> Date: Sat, 14 Jun 2025 15:29:49 +0200 Subject: [PATCH 05/46] Typo --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 14a81c13..9e6b7015 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -154,7 +154,7 @@ Caricamento aggiornamento… Quando navighi tra i video online e desideri lasciare un commento, prendi in considerazione Polycentric, un nuovo modo di gestire la tua presenza online che è sia facile da usare che incentrato sulle tue esigenze. Ecco perché creare un profilo Polycentric è un\'ottima scelta:\n\n 1. Hai il controllo: Con Polycentric, i tuoi dati non sono archiviati in un unico luogo controllato da una singola azienda. Invece, sono distribuiti su più posizioni, offrendoti un maggiore controllo sulla tua presenza online. Decidi tu dove vengono archiviati i tuoi dati e puoi facilmente passare da un fornitore all\'altro o aggiungerne di nuovi.\n\n 2. Privacy e sicurezza: Polycentric mantiene le tue informazioni al sicuro utilizzando tecniche di sicurezza avanzate. Puoi star certo che i tuoi dati personali sono ben protetti e accessibili solo a coloro con cui scegli di condividerli.\n\n 3. Networking senza interruzioni: Polycentric ti consente di connetterti con altri e interagire con i loro contenuti senza essere legato a un\'unica piattaforma. Se un fornitore diventa inaffidabile o tenta di bloccare l\'accesso, il tuo client Polycentric troverà automaticamente le informazioni da altre fonti, mantenendoti connesso.\n\n 4. Ricerca e suggerimenti intelligenti: Polycentric utilizza più fonti per la ricerca ed i suggerimenti, offrendoti prestazioni di prim\'ordine e garantendo che nessun singolo fornitore abbia eccessiva influenza sui risultati che vedi.\n\n 5. Adattabile e aperto: Polycentric è progettato per essere flessibile e aperto a nuovi sviluppi, consentendo miglioramenti costanti e nuove funzionalità che si adattano alle esigenze dei suoi utenti.\n\n 6. Creando un profilo Polycentric, puoi lasciare commenti sui video online e goderti un\'esperienza più personalizzata. Scopri tu stesso come questa soluzione intuitiva ti mette al centro della tua vita digitale. Iscriviti oggi stesso a Polycentric! Pubblica - Uso + Utilizzo Attiva in home Attiva in ricerca Verifica From c4d06c1ba2efcc42c74ab24569c43c0060131085 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 1 Aug 2025 21:55:47 +0200 Subject: [PATCH 06/46] Hide sync ui, thumbnails nullable --- .../platformplayer/api/media/LiveChatManager.kt | 2 -- .../models/video/SerializedPlatformVideo.kt | 3 ++- .../platforms/js/SourcePluginDescriptor.kt | 17 ++--------------- .../mainactivity/main/SourceDetailFragment.kt | 5 +++++ 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt index da7302fb..b6f61a33 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt @@ -123,8 +123,6 @@ class LiveChatManager { val requestPosition = _position; _pager.nextPage(requestPosition.toInt()); var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis }; - //TODO: Remove this once dripfeed is done properly - replayResults = replayResults.filter{ it.time < requestPosition + 1500 || it is LiveEventEmojis }; if(replayResults.size > 0) { _eventsPosition = replayResults.maxOf { it.time }; Logger.i(TAG, "VOD Events last event: " + _eventsPosition); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index eadffc8d..7388dfa8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.video import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer @@ -16,7 +17,7 @@ open class SerializedPlatformVideo( override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, - override val thumbnails: Thumbnails, + override val thumbnails: Thumbnails = Thumbnails(), override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @JsonNames("datetime", "dateTime") diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index 55921618..771e1feb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -116,25 +116,12 @@ class SourcePluginDescriptor { var enableShorts: Boolean? = null; } - @FormField(R.string.sync, "group", R.string.sync_desc, 3) + @FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync") var sync = Sync(); @Serializable class Sync { - @FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1) + @FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory") var enableHistorySync: Boolean? = null; - - @FormField(R.string.sync_history, FieldForm.BUTTON, R.string.sync_history_desc, 2) - @FormFieldButton() - fun syncHistoryNow() { - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val clients = StatePlatform.instance.getEnabledClients(); - for (client in clients) { - if (client is JSClient) {//) && client.descriptor.appSettings.sync.enableHistorySync == true) { - StateHistory.instance.syncRemoteHistory(client); - } - } - }; - } } @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt index 1b621c03..5c2a8085 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -152,6 +152,11 @@ class SourceDetailFragment : MainFragment() { if(field is View) field.isVisible = false; } + if(!source.capabilities.hasGetUserHistory) { + val field = _settingsAppForm.findField("sync"); + if(field is View) + field.isVisible = false; + } _settingsAppForm.onChanged.clear(); _settingsAppForm.onChanged.subscribe { field, value -> _settingsAppChanged = true; From 8d08e19cd2d514180ffbfd43681643a82ad7692b Mon Sep 17 00:00:00 2001 From: Stefan <84-stefancruz@users.noreply.gitlab.futo.org> Date: Thu, 7 Aug 2025 08:00:17 +0100 Subject: [PATCH 07/46] Add mixcloud plugin --- .gitmodules | 6 ++++++ app/src/stable/assets/sources/mixcloud | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/assets/sources/mixcloud | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) create mode 160000 app/src/stable/assets/sources/mixcloud create mode 160000 app/src/unstable/assets/sources/mixcloud diff --git a/.gitmodules b/.gitmodules index 00037939..cfc51de0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -106,3 +106,9 @@ [submodule "app/src/stable/assets/sources/crunchyroll"] path = app/src/stable/assets/sources/crunchyroll url = ../plugins/crunchyroll.git +[submodule "app/src/stable/assets/sources/mixcloud"] + path = app/src/stable/assets/sources/mixcloud + url = ../plugins/mixcloud.git +[submodule "app/src/unstable/assets/sources/mixcloud"] + path = app/src/unstable/assets/sources/mixcloud + url = ../plugins/mixcloud.git diff --git a/app/src/stable/assets/sources/mixcloud b/app/src/stable/assets/sources/mixcloud new file mode 160000 index 00000000..0bbe4c63 --- /dev/null +++ b/app/src/stable/assets/sources/mixcloud @@ -0,0 +1 @@ +Subproject commit 0bbe4c63f42f8bcb889d2e81840351fd7579c2ef diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index ce535388..78a83514 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -16,7 +16,8 @@ "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", - "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" + "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json", + "84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/assets/sources/mixcloud b/app/src/unstable/assets/sources/mixcloud new file mode 160000 index 00000000..0bbe4c63 --- /dev/null +++ b/app/src/unstable/assets/sources/mixcloud @@ -0,0 +1 @@ +Subproject commit 0bbe4c63f42f8bcb889d2e81840351fd7579c2ef diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index 5abe547e..3b8f034f 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -16,7 +16,8 @@ "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", - "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" + "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json", + "84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From 0c5ba0cd39e19c7ed59d96feb51d108c90af761a Mon Sep 17 00:00:00 2001 From: quonverbat Date: Sun, 10 Aug 2025 16:22:56 +0300 Subject: [PATCH 08/46] Fix typos --- .github/ISSUE_TEMPLATE/1-bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.yml b/.github/ISSUE_TEMPLATE/1-bug_report.yml index e2108e3e..d3f9ee8c 100644 --- a/.github/ISSUE_TEMPLATE/1-bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug_report.yml @@ -26,7 +26,7 @@ body: label: Reproduction steps description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible. placeholder: | - 0. Play a Youtube video + 0. Play a YouTube video 1. Press on Download button 2. Select quality 1440p 3. Grayjay crashes when attempting to download @@ -83,7 +83,7 @@ body: - "Spotify" - "TedTalks" - "Twitch" - - "Youtube" + - "YouTube" - "Other" validations: required: true From d6a23ac0de2a9bb70d121242507da51c5917d42f Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 11 Aug 2025 14:48:23 -0500 Subject: [PATCH 09/46] fix PiP issue reproduction steps - play a video - swipe home to enter PiP - minimize the video and then close it with the X - swipe home (PiP will launch even though it shouldn't because nothing is playing) Changelog: changed --- .../platformplayer/fragment/mainactivity/main/VideoDetailView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 5382ef13..7ac846f3 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1207,6 +1207,7 @@ class VideoDetailView : ConstraintLayout { _taskLoadVideo.cancel(); handleStop(); _didStop = true; + onShouldEnterPictureInPictureChanged.emit() Logger.i(TAG, "_didStop set to true"); StatePlayer.instance.rotationLock = false; From 1507c70729792c4110925f3a3d693f5615957bbd Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 11 Aug 2025 16:45:03 -0400 Subject: [PATCH 10/46] fix https://github.com/futo-org/grayjay-android/issues/2585 Changelog: changed --- app/src/main/assets/scripts/source.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 85195f47..4156abca 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -467,14 +467,20 @@ class AudioUrlWidevineSource extends AudioUrlSource { this.getLicenseRequestExecutor = () => { return { executeRequest: (url, _headers, _method, license_request_data) => { - return http.POST( + const response = http.POST( url, license_request_data, { Authorization: `Bearer ${obj.bearerToken}` }, false, true - ).body - } + ); + + if (!response.body) { + throw new ScriptException("Unable to acquire license key"); + } + + return response.body; + } } } } From 6cf47d592a11a1d5a7c5809dabe6da05116af2d3 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 12 Aug 2025 02:03:04 +0200 Subject: [PATCH 11/46] Various shorts improvements, login warnings support, etc --- app/build.gradle | 4 +- .../activities/LoginActivity.kt | 15 + .../platforms/js/SourcePluginAuthConfig.kt | 31 +- .../MultiDistributionContentPager.kt | 2 +- .../fragment/mainactivity/main/ShortView.kt | 589 +++--------------- .../mainactivity/main/ShortsFragment.kt | 45 +- .../special/CommentsModalBottomSheet.kt | 454 ++++++++++++++ .../platformplayer/states/StatePlatform.kt | 2 +- .../views/buttons/ShortsButton.kt | 117 ++++ .../views/others/CreatorThumbnail.kt | 7 +- app/src/main/res/drawable/ic_comment_s.xml | 12 + app/src/main/res/drawable/ic_settings_s.xml | 11 + app/src/main/res/drawable/ic_share_s.xml | 11 + app/src/main/res/drawable/ic_thumb_down_s.xml | 11 + .../res/drawable/ic_thumb_down_s_filled.xml | 10 + app/src/main/res/drawable/ic_thumb_up_s.xml | 11 + .../res/drawable/ic_thumb_up_s_filled.xml | 10 + .../main/res/layout/view_short_overlay.xml | 384 +++--------- app/src/main/res/layout/view_short_player.xml | 6 +- .../main/res/layout/view_shorts_button.xml | 28 + .../main/res/values/shorts_button_attrs.xml | 7 + app/src/stable/assets/sources/apple-podcasts | 2 +- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/peertube | 2 +- app/src/stable/assets/sources/rumble | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- .../unstable/assets/sources/apple-podcasts | 2 +- app/src/unstable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/peertube | 2 +- app/src/unstable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 33 files changed, 932 insertions(+), 859 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/special/CommentsModalBottomSheet.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/buttons/ShortsButton.kt create mode 100644 app/src/main/res/drawable/ic_comment_s.xml create mode 100644 app/src/main/res/drawable/ic_settings_s.xml create mode 100644 app/src/main/res/drawable/ic_share_s.xml create mode 100644 app/src/main/res/drawable/ic_thumb_down_s.xml create mode 100644 app/src/main/res/drawable/ic_thumb_down_s_filled.xml create mode 100644 app/src/main/res/drawable/ic_thumb_up_s.xml create mode 100644 app/src/main/res/drawable/ic_thumb_up_s_filled.xml create mode 100644 app/src/main/res/layout/view_shorts_button.xml create mode 100644 app/src/main/res/values/shorts_button_attrs.xml diff --git a/app/build.gradle b/app/build.gradle index 25d458d4..65f600c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,10 +154,10 @@ android { } dependencies { - implementation 'com.google.dagger:dagger:2.48' + //implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' implementation 'com.google.android.material:material:1.12.0' - annotationProcessor 'com.google.dagger:dagger-compiler:2.48' + //annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core implementation 'androidx.core:core-ktx:1.12.0' diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt index 6ea7bd67..1dc4376e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp @@ -74,6 +75,7 @@ class LoginActivity : AppCompatActivity() { finish(); }; var isFirstLoad = true; + val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf(); webViewClient.onPageLoaded.subscribe { view, url -> _textUrl.setText(url ?: ""); @@ -86,6 +88,19 @@ class LoginActivity : AppCompatActivity() { //TODO: Find most reliable way to wait for page js to finish view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); } + + if(loginWarnings.size > 0) { + synchronized(loginWarnings) { + val warning = loginWarnings.find { it.url.matches(it.getRegex()) }; + if(warning != null) { + if(warning.once == true) + loginWarnings.remove(warning); + UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0, + UIDialogs.Action("Understood", { + }, UIDialogs.ActionStyle.PRIMARY)); + } + } + } } _webView.settings.domStorageEnabled = true; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt index 439e8d82..8821c79e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt @@ -1,6 +1,10 @@ package com.futo.platformplayer.api.media.platforms.js -@kotlinx.serialization.Serializable +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.Dictionary + +@Serializable class SourcePluginAuthConfig( val loginUrl: String, val completionUrl: String? = null, @@ -11,5 +15,26 @@ class SourcePluginAuthConfig( val userAgent: String? = null, val loginButton: String? = null, val domainHeadersToFind: Map>? = null, - val loginWarning: String? = null -) { } \ No newline at end of file + val loginWarning: String? = null, + val loginWarnings: List? = null +) { + + @Serializable + class Warning( + val url: String, + val text: String?, + val details: String? = null, + val once: Boolean? = true + ) { + @Contextual + private var _regex: Regex? = null; + + fun getRegex(): Regex { + return _regex ?: url.let { + val reg = Regex(it); + _regex = reg; + return reg; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index 66554572..4380fea0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -12,7 +12,7 @@ class MultiDistributionContentPager : MultiPager { private val dist : HashMap, Float>; private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) { val distTotal = pagers.values.sum(); dist = HashMap(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt index 478c2732..2ec3d993 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -1,46 +1,27 @@ package com.futo.platformplayer.fragment.mainactivity.main -import android.app.Dialog import android.content.Context -import android.content.DialogInterface import android.content.Intent -import android.graphics.Bitmap import android.graphics.drawable.Animatable -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.text.Spanned import android.util.AttributeSet -import android.util.TypedValue import android.view.LayoutInflater -import android.view.SoundEffectConstants -import android.view.View import android.view.animation.AccelerateInterpolator import android.view.animation.OvershootInterpolator -import android.widget.Button import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.graphics.drawable.toDrawable -import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.util.UnstableApi -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException -import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink -import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes -import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource @@ -54,40 +35,28 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.downloads.VideoLocal -import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException -import com.futo.platformplayer.fixHtmlLinks -import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSize -import com.futo.platformplayer.toHumanNowDiffString -import com.futo.platformplayer.toHumanNumber -import com.futo.platformplayer.views.MonetizationView -import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.buttons.ShortsButton import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.overlays.DescriptionOverlay -import com.futo.platformplayer.views.overlays.RepliesOverlay -import com.futo.platformplayer.views.overlays.SupportOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem @@ -95,20 +64,15 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.video.FutoShortPlayer import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models import com.futo.polycentric.core.Opinion -import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions -import com.futo.polycentric.core.toURLInfoSystemLinkUrl -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.button.MaterialButton +//import com.google.android.material.button.MaterialButton import com.google.protobuf.ByteString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -116,30 +80,29 @@ import userpackage.Protocol @UnstableApi class ShortView : FrameLayout { - private lateinit var mainFragment: MainFragment + private lateinit var fragment: MainFragment private val player: FutoShortPlayer private val channelInfo: LinearLayout private val creatorThumbnail: CreatorThumbnail private val channelName: TextView private val videoTitle: TextView + private val videoSubtitle: TextView private val platformIndicator: PlatformIndicator + //TODO: Replace with non-material button private val backButton: MaterialButton private val backButtonContainer: ConstraintLayout - private val likeContainer: FrameLayout - private val dislikeContainer: FrameLayout - private val likeButton: MaterialButton - private val likeCount: TextView - private val dislikeButton: MaterialButton - private val dislikeCount: TextView + private val likeButton: ShortsButton + //private val likeCount: TextView + private val dislikeButton: ShortsButton + //private val dislikeCount: TextView - private val commentsButton: MaterialButton - private val shareButton: MaterialButton - private val refreshButton: MaterialButton - private val refreshButtonContainer: View - private val qualityButton: MaterialButton + private val commentsButton: ShortsButton + private val shareButton: ShortsButton + private val refreshButton: ShortsButton + private val qualityButton: ShortsButton private val playPauseOverlay: FrameLayout private val playPauseIcon: ImageView @@ -173,18 +136,21 @@ class ShortView : FrameLayout { private val onLikeDislikeUpdated = Event1() private val onVideoUpdated = Event1() + //TODO: Replace with non-material UI? Only true dependency on Material left private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() var likes: Long = 0 set(value) { field = value - likeCount.text = value.toString() + likeButton.withPrimaryText(value.toString()); + //likeCount.text = value.toString() } var dislikes: Long = 0 set(value) { field = value - dislikeCount.text = value.toString() + dislikeButton.withPrimaryText(value.toString()); + //dislikeCount.text = value.toString() } constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { @@ -194,7 +160,7 @@ class ShortView : FrameLayout { LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT ) - this.mainFragment = fragment + this.fragment = fragment bottomSheet.mainFragment = fragment } @@ -217,19 +183,17 @@ class ShortView : FrameLayout { creatorThumbnail = findViewById(R.id.creator_thumbnail) channelName = findViewById(R.id.channel_name) videoTitle = findViewById(R.id.video_title) + videoSubtitle = findViewById(R.id.video_subtitle) platformIndicator = findViewById(R.id.short_platform_indicator) backButton = findViewById(R.id.back_button) backButtonContainer = findViewById(R.id.back_button_container) - likeContainer = findViewById(R.id.like_container) - dislikeContainer = findViewById(R.id.dislike_container) likeButton = findViewById(R.id.like_button) - likeCount = findViewById(R.id.like_count) + //likeCount = findViewById(R.id.like_count) dislikeButton = findViewById(R.id.dislike_button) - dislikeCount = findViewById(R.id.dislike_count) + //dislikeCount = findViewById(R.id.dislike_count) commentsButton = findViewById(R.id.comments_button) shareButton = findViewById(R.id.share_button) refreshButton = findViewById(R.id.refresh_button) - refreshButtonContainer = findViewById(R.id.refresh_button_container) qualityButton = findViewById(R.id.quality_button) playPauseOverlay = findViewById(R.id.play_pause_overlay) playPauseIcon = findViewById(R.id.play_pause_icon) @@ -258,48 +222,44 @@ class ShortView : FrameLayout { } onVideoUpdated.subscribe { + Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})"); videoTitle.text = it?.name + videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else ""; platformIndicator.setPlatformFromClientID(it?.id?.pluginId) creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) channelName.text = it?.author?.name } backButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - mainFragment.closeSegment() + fragment.closeSegment() } channelInfo.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - mainFragment.navigate(video?.author) + fragment.navigate(video?.author) } videoTitle.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) if (!bottomSheet.isAdded) { - bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG) } } - commentsButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) + commentsButton.onClick.subscribe { if (!bottomSheet.isAdded) { - bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG) } } - shareButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) + shareButton.onClick.subscribe { val url = video?.shareUrl ?: video?.url - mainFragment.startActivity(Intent.createChooser(Intent().apply { + fragment.startActivity(Intent.createChooser(Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, url) type = "text/plain" }, null)) } - refreshButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) + refreshButton.onClick.subscribe { onResetTriggered.emit() } @@ -308,14 +268,12 @@ class ShortView : FrameLayout { false } - qualityButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) + qualityButton.onClick.subscribe { showVideoSettings() } - likeButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - val checked = !likeButton.isChecked + likeButton.onClick.subscribe { + val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { if (checked) { likes++ @@ -323,24 +281,27 @@ class ShortView : FrameLayout { likes-- } - likeButton.isChecked = checked + if(checked) + likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked + else + likeButton.withIcon(R.drawable.ic_thumb_up_s) - if (dislikeButton.isChecked && checked) { - dislikeButton.isChecked = false + if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) { + //dislikeButton.isChecked = false + dislikeButton.withIcon(R.drawable.ic_thumb_down_s) dislikes-- } onLikeDislikeUpdated.emit( OnLikeDislikeUpdatedArgs( - it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + it, likes, checked, dislikes, !checked ) ) } } - dislikeButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - val checked = !dislikeButton.isChecked + dislikeButton.onClick.subscribe { + val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { if (checked) { dislikes++ @@ -348,16 +309,21 @@ class ShortView : FrameLayout { dislikes-- } - dislikeButton.isChecked = checked + //dislikeButton.isChecked = checked + if(checked) + dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked + else + dislikeButton.withIcon(R.drawable.ic_thumb_down_s) - if (likeButton.isChecked && checked) { - likeButton.isChecked = false + if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) { + //likeButton.isChecked = false + likeButton.withIcon(R.drawable.ic_thumb_up_s); likes-- } onLikeDislikeUpdated.emit( OnLikeDislikeUpdatedArgs( - it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + it, likes, !checked, dislikes, checked ) ) } @@ -366,11 +332,11 @@ class ShortView : FrameLayout { onLikesLoaded.subscribe(tag) { rating, liked, disliked -> likes = rating.likes dislikes = rating.dislikes - likeButton.isChecked = liked - dislikeButton.isChecked = disliked + //likeButton.isChecked = liked + //dislikeButton.isChecked = disliked - dislikeContainer.visibility = VISIBLE - likeContainer.visibility = VISIBLE + dislikeButton.visibility = VISIBLE + likeButton.visibility = VISIBLE } player.onPlaybackStateChanged.subscribe { @@ -565,7 +531,7 @@ class ShortView : FrameLayout { var toSet: ISubtitleSource? = subtitleSource if (_lastSubtitleSource == subtitleSource) toSet = null - mainFragment.lifecycleScope.launch(Dispatchers.Main) { + fragment.lifecycleScope.launch(Dispatchers.Main) { try { player.swapSubtitles(toSet) } catch (e: Throwable) { @@ -625,7 +591,7 @@ class ShortView : FrameLayout { @Suppress("unused") fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { - this.mainFragment = fragment + this.fragment = fragment this.bottomSheet.mainFragment = fragment this.overlayQualityContainer = overlayQualityContainer } @@ -636,10 +602,10 @@ class ShortView : FrameLayout { } this.video = video - refreshButtonContainer.visibility = if (isChannelShortsMode) { + refreshButton.visibility = if (isChannelShortsMode) { GONE } else { - VISIBLE + GONE //TODO: Revert? } backButtonContainer.visibility = if (isChannelShortsMode) { VISIBLE @@ -695,8 +661,8 @@ class ShortView : FrameLayout { } private fun loadLikes(video: IPlatformVideo) { - likeContainer.visibility = GONE - dislikeContainer.visibility = GONE + likeButton.visibility = GONE + dislikeButton.visibility = GONE loadLikesTask?.cancel() loadLikesTask = @@ -735,13 +701,13 @@ class ShortView : FrameLayout { args.processHandle.opinion(ref, Opinion.neutral) } - mainFragment.lifecycleScope.launch(Dispatchers.IO) { + fragment.lifecycleScope.launch(Dispatchers.IO) { try { - Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") + Logger.i(TAG, "Started backfill") args.processHandle.fullyBackfillServersAnnounceExceptions() - Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") + Logger.i(TAG, "Finished backfill") } catch (e: Throwable) { - Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) + Logger.e(TAG, "Failed to backfill servers", e) } } @@ -763,20 +729,24 @@ class ShortView : FrameLayout { setLoading(true) + Logger.i(TAG, "Shorts loadVideo [${url}]"); + val timeLoadVideoStart = System.currentTimeMillis(); loadVideoTask = TaskHandler( StateApp.instance.scopeGetter, { val result = StatePlatform.instance.getContentDetails(it).await() if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") return@TaskHandler result }).success { result -> - videoDetails = result - video = result + val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart; + Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms"); + videoDetails = result + video = result - bottomSheet.video = result + bottomSheet.video = result - setLoading(false) + setLoading(false) - if (playWhenReady) playVideo() + if (playWhenReady) playVideo() }.exception { Logger.w(TAG, "exception", it) UIDialogs.showDialog( @@ -799,7 +769,7 @@ class ShortView : FrameLayout { UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } }.exception { Logger.w(TAG, "exception", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment) }.exception { Logger.w(TAG, "exception", it) UIDialogs.showDialog( @@ -812,10 +782,10 @@ class ShortView : FrameLayout { ) }.exception { Logger.w(TAG, "exception", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment) }.exception { Logger.w(ChannelFragment.TAG, "Failed to load video.", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment) } loadVideoTask?.run(url) @@ -849,6 +819,7 @@ class ShortView : FrameLayout { } val thumbnail = videoDetails.thumbnails.getHQThumbnail() + /* if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() .load(thumbnail).into(object : CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { @@ -860,8 +831,9 @@ class ShortView : FrameLayout { } }) else player.setArtwork(null) + */ - mainFragment.lifecycleScope.launch(Dispatchers.Main) { + fragment.lifecycleScope.launch(Dispatchers.Main) { try { player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) if (subtitleSource != null) player.swapSubtitles(subtitleSource) @@ -887,397 +859,4 @@ class ShortView : FrameLayout { const val TAG = "VideoDetailView" } - class CommentsModalBottomSheet : BottomSheetDialogFragment() { - var mainFragment: MainFragment? = null - - private lateinit var containerContent: FrameLayout - private lateinit var containerContentMain: LinearLayout - private lateinit var containerContentReplies: RepliesOverlay - private lateinit var containerContentDescription: DescriptionOverlay - private lateinit var containerContentSupport: SupportOverlay - - private lateinit var title: TextView - private lateinit var subTitle: TextView - private lateinit var channelName: TextView - private lateinit var channelMeta: TextView - private lateinit var creatorThumbnail: CreatorThumbnail - private lateinit var channelButton: LinearLayout - private lateinit var monetization: MonetizationView - private lateinit var platform: PlatformIndicator - private lateinit var textLikes: TextView - private lateinit var textDislikes: TextView - private lateinit var layoutRating: LinearLayout - private lateinit var imageDislikeIcon: ImageView - private lateinit var imageLikeIcon: ImageView - - private lateinit var description: TextView - private lateinit var descriptionContainer: LinearLayout - private lateinit var descriptionViewMore: TextView - - private lateinit var commentsList: CommentsList - private lateinit var addCommentView: AddCommentView - - private var polycentricProfile: PolycentricProfile? = null - - private lateinit var buttonPolycentric: Button - private lateinit var buttonPlatform: Button - - private var tabIndex: Int? = null - - private var contentOverlayView: View? = null - - lateinit var video: IPlatformVideoDetails - - private lateinit var behavior: BottomSheetBehavior - - private val _taskLoadPolycentricProfile = - TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load claims.", it) - } - - override fun onCreateDialog( - savedInstanceState: Bundle?, - ): Dialog { - val bottomSheetDialog = - BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) - bottomSheetDialog.setContentView(R.layout.modal_comments) - - behavior = bottomSheetDialog.behavior - - // TODO figure out how to not need all of these non null assertions - containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! - containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! - containerContentReplies = - bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! - containerContentDescription = - bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! - containerContentSupport = - bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! - - title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! - subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! - channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! - channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! - creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! - channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! - monetization = bottomSheetDialog.findViewById(R.id.monetization)!! - platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! - layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! - textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! - textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! - imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! - imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! - - description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! - descriptionContainer = - bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! - descriptionViewMore = - bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! - - addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! - commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! - buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! - buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! - - commentsList.onAuthorClick.subscribe { c -> - if (c !is PolycentricPlatformComment) { - return@subscribe - } - val id = c.author.id.value - - Logger.i(TAG, "onAuthorClick: $id") - if (id != null && id.startsWith("polycentric://")) { - val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) - mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) - } - } - commentsList.onRepliesClick.subscribe { c -> - val replyCount = c.replyCount ?: 0 - var metadata = "" - if (replyCount > 0) { - metadata += "$replyCount " + requireContext().getString(R.string.replies) - } - - if (c is PolycentricPlatformComment) { - var parentComment: PolycentricPlatformComment = c - containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { - val newComment = parentComment.cloneWithUpdatedReplyCount( - (parentComment.replyCount ?: 0) + 1 - ) - commentsList.replaceComment(parentComment, newComment) - parentComment = newComment - }) - } else { - containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) - } - animateOpenOverlayView(containerContentReplies) - } - - if (StatePolycentric.instance.enabled) { - buttonPolycentric.setOnClickListener { - setTabIndex(0) - StateMeta.instance.setLastCommentSection(0) - } - } else { - buttonPolycentric.visibility = GONE - } - - buttonPlatform.setOnClickListener { - setTabIndex(1) - StateMeta.instance.setLastCommentSection(1) - } - - val ref = Models.referenceFromBuffer(video.url.toByteArray()) - addCommentView.setContext(video.url, ref) - - if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { - setTabIndex(2, true) - } else { - when (Settings.instance.comments.defaultCommentSection) { - 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) - 1 -> setTabIndex(1, true) - 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) - } - } - - containerContentDescription.onClose.subscribe { animateCloseOverlayView() } - containerContentReplies.onClose.subscribe { animateCloseOverlayView() } - - descriptionViewMore.setOnClickListener { - animateOpenOverlayView(containerContentDescription) - } - - updateDescriptionUI(video.description.fixHtmlLinks()) - - val dp5 = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) - val dp2 = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) - - //UI - title.text = video.name - channelName.text = video.author.name - if (video.author.subscribers != null) { - channelMeta.text = if ((video.author.subscribers - ?: 0) > 0 - ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" - (channelName.layoutParams as MarginLayoutParams).setMargins( - 0, (dp5 * -1).toInt(), 0, 0 - ) - } else { - channelMeta.text = "" - (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) - } - - video.author.let { - if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) - else monetization.setPlatformMembership(null, null) - } - - val subTitleSegments: ArrayList = ArrayList() - if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") - if (video.datetime != null) { - val diff = video.datetime?.getNowDiffSeconds() ?: 0 - val ago = video.datetime?.toHumanNowDiffString(true) - if (diff >= 0) subTitleSegments.add("$ago ago") - else subTitleSegments.add("available in $ago") - } - - platform.setPlatformFromClientID(video.id.pluginId) - subTitle.text = subTitleSegments.joinToString(" • ") - creatorThumbnail.setThumbnail(video.author.thumbnail, false) - - setPolycentricProfile(null, animate = false) - _taskLoadPolycentricProfile.run(video.author.id) - - when (video.rating) { - is RatingLikeDislikes -> { - val r = video.rating as RatingLikeDislikes - layoutRating.visibility = VISIBLE - - textLikes.visibility = VISIBLE - imageLikeIcon.visibility = VISIBLE - textLikes.text = r.likes.toHumanNumber() - - imageDislikeIcon.visibility = VISIBLE - textDislikes.visibility = VISIBLE - textDislikes.text = r.dislikes.toHumanNumber() - } - - is RatingLikes -> { - val r = video.rating as RatingLikes - layoutRating.visibility = VISIBLE - - textLikes.visibility = VISIBLE - imageLikeIcon.visibility = VISIBLE - textLikes.text = r.likes.toHumanNumber() - - imageDislikeIcon.visibility = GONE - textDislikes.visibility = GONE - } - - else -> { - layoutRating.visibility = GONE - } - } - - monetization.onSupportTap.subscribe { - containerContentSupport.setPolycentricProfile(polycentricProfile) - animateOpenOverlayView(containerContentSupport) - } - - monetization.onStoreTap.subscribe { - polycentricProfile?.systemState?.store?.let { - try { - val uri = it.toUri() - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - requireContext().startActivity(intent) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to open URI: '${it}'.", e) - } - } - } - monetization.onUrlTap.subscribe { - mainFragment!!.navigate(it) - } - - addCommentView.onCommentAdded.subscribe { - commentsList.addComment(it) - } - - channelButton.setOnClickListener { - mainFragment!!.navigate(video.author) - } - - return bottomSheetDialog - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - animateCloseOverlayView() - } - - private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { - polycentricProfile = profile - - val dp35 = 35.dp(requireContext().resources) - val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } - - if (avatar != null) { - creatorThumbnail.setThumbnail(avatar, animate) - } else { - creatorThumbnail.setThumbnail(video.author.thumbnail, animate) - creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) - } - - val username = profile?.systemState?.username - if (username != null) { - channelName.text = username - } - - monetization.setPolycentricProfile(profile) - } - - private fun setTabIndex(index: Int?, forceReload: Boolean = false) { - Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") - val changed = tabIndex != index || forceReload - if (!changed) { - return - } - - tabIndex = index - buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) - buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) - - when (index) { - null -> { - addCommentView.visibility = GONE - commentsList.clear() - } - - 0 -> { - addCommentView.visibility = VISIBLE - fetchPolycentricComments() - } - - 1 -> { - addCommentView.visibility = GONE - fetchComments() - } - } - } - - private fun fetchComments() { - Logger.i(TAG, "fetchComments") - video.let { - commentsList.load(true) { StatePlatform.instance.getComments(it) } - } - } - - private fun fetchPolycentricComments() { - Logger.i(TAG, "fetchPolycentricComments") - val video = video - val idValue = video.id.value - if (video.url.isEmpty()) { - Logger.w(TAG, "Failed to fetch polycentric comments because url was null") - commentsList.clear() - return - } - - val ref = Models.referenceFromBuffer(video.url.toByteArray()) - val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } - commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } - } - - private fun updateDescriptionUI(text: Spanned) { - containerContentDescription.load(text) - description.text = text - - if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE - else descriptionContainer.visibility = GONE - } - - private fun animateOpenOverlayView(view: View) { - if (contentOverlayView != null) { - Logger.e(TAG, "Content overlay already open") - return - } - - behavior.isDraggable = false - behavior.state = BottomSheetBehavior.STATE_EXPANDED - - val animHeight = containerContentMain.height - - view.translationY = animHeight.toFloat() - view.visibility = VISIBLE - - view.animate().setDuration(300).translationY(0f).withEndAction { - contentOverlayView = view - }.start() - } - - private fun animateCloseOverlayView() { - val curView = contentOverlayView - if (curView == null) { - Logger.e(TAG, "No content overlay open") - return - } - - behavior.isDraggable = true - - val animHeight = contentOverlayView!!.height - - curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { - curView.visibility = GONE - contentOverlayView = null - }.start() - } - - companion object { - const val TAG = "ModalBottomSheet" - } - } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt index f082c91d..61e91199 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -11,6 +11,7 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import androidx.annotation.OptIn +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 @@ -25,6 +26,9 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.buttons.BigButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.system.measureTimeMillis @UnstableApi class ShortsFragment : MainFragment() { @@ -35,6 +39,7 @@ class ShortsFragment : MainFragment() { private var loadPagerTask: TaskHandler>? = null private var nextPageTask: TaskHandler>? = null + //TODO: Reduce number of pagers (1, or at most 2) private var mainShortsPager: IPager? = null private val mainShorts: MutableList = mutableListOf() @@ -58,6 +63,7 @@ class ShortsFragment : MainFragment() { private var customViewAdapter: CustomViewAdapter? = null // we just completely reset the data structure so we want to tell the adapter that + //TODO: Move most of this logic to ShortsView @SuppressLint("NotifyDataSetChanged") override fun onShownWithView(parameter: Any?, isBack: Boolean) { (activity as MainActivity?)?.getFragment()?.closeVideoDetails() @@ -118,7 +124,6 @@ class ShortsFragment : MainFragment() { overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) sourcesButton.onClick.subscribe { - sourcesButton.playSoundEffect(SoundEffectConstants.CLICK) navigate() } @@ -145,7 +150,7 @@ class ShortsFragment : MainFragment() { this.customViewAdapter = customViewAdapter - if (loadPagerTask == null && currentShorts.isEmpty()) { + if (loadPagerTask == null) {// && currentShorts.isEmpty()) { loadPager() loadPagerTask!!.success { @@ -207,28 +212,29 @@ class ShortsFragment : MainFragment() { } private fun nextPage() { - nextPageTask?.cancel() - - val nextPageTask = - TaskHandler>(StateApp.instance.scopeGetter, { - currentShortsPager!!.nextPage() - - return@TaskHandler currentShortsPager!!.getResults() - }).success { newVideos -> + Logger.i(TAG, "ShortsFragment nextPage"); + lifecycleScope.launch(Dispatchers.IO) { + try { + val time = measureTimeMillis { + currentShortsPager!!.nextPage(); + } + val newVideos = currentShortsPager!!.getResults(); val prevCount = customViewAdapter!!.itemCount + Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}"); currentShorts.addAll(newVideos) if (isChannelShortsMode) { channelShorts.addAll(newVideos) } else { mainShorts.addAll(newVideos) } - customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + lifecycleScope.launch(Dispatchers.Main) { + customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + } nextPageTask = null + } catch (ex: Throwable) { + Logger.e(TAG, "Shorts Failed to call nextPage", ex); } - - nextPageTask.run(this) - - this.nextPageTask = nextPageTask + } } // we just completely reset the data structure so we want to tell the adapter that @@ -236,12 +242,16 @@ class ShortsFragment : MainFragment() { private fun loadPager() { loadPagerTask?.cancel() + Logger.i(TAG, "Shorts loadPage"); + var loadPageStart = System.currentTimeMillis(); val loadPagerTask = TaskHandler>(StateApp.instance.scopeGetter, { - val pager = StatePlatform.instance.getShorts() + val pager = StatePlatform.instance.getShorts(); return@TaskHandler pager }).success { pager -> + val timeLoadPage = System.currentTimeMillis() - loadPageStart; + Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms"); mainShorts.clear() mainShorts.addAll(pager.getResults()) mainShortsPager = pager @@ -259,7 +269,7 @@ class ShortsFragment : MainFragment() { loadPagerTask = null }.exception { err -> val message = "Unable to load shorts $err" - Logger.i(TAG, message) + Logger.w(TAG, message, err) if (context != null) { UIDialogs.showDialog( requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( @@ -329,6 +339,7 @@ class ShortsFragment : MainFragment() { @OptIn(UnstableApi::class) override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})") holder.shortView.changeVideo(videos[position], isChannelShortsMode()) if (position == itemCount - 1) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/special/CommentsModalBottomSheet.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/special/CommentsModalBottomSheet.kt new file mode 100644 index 00000000..c74a7218 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/special/CommentsModalBottomSheet.kt @@ -0,0 +1,454 @@ +package com.futo.platformplayer.fragment.mainactivity.special + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.text.Spanned +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.FrameLayout +import android.widget.FrameLayout.GONE +import android.widget.FrameLayout.VISIBLE +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.net.toUri +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.fragment.mainactivity.main.MainFragment +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateMeta +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.MonetizationView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.DescriptionOverlay +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.overlays.SupportOverlay +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.toURLInfoSystemLinkUrl + +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + + +class CommentsModalBottomSheet : BottomSheetDialogFragment() { + var mainFragment: MainFragment? = null + + private lateinit var containerContent: FrameLayout + private lateinit var containerContentMain: LinearLayout + private lateinit var containerContentReplies: RepliesOverlay + private lateinit var containerContentDescription: DescriptionOverlay + private lateinit var containerContentSupport: SupportOverlay + + private lateinit var title: TextView + private lateinit var subTitle: TextView + private lateinit var channelName: TextView + private lateinit var channelMeta: TextView + private lateinit var creatorThumbnail: CreatorThumbnail + private lateinit var channelButton: LinearLayout + private lateinit var monetization: MonetizationView + private lateinit var platform: PlatformIndicator + private lateinit var textLikes: TextView + private lateinit var textDislikes: TextView + private lateinit var layoutRating: LinearLayout + private lateinit var imageDislikeIcon: ImageView + private lateinit var imageLikeIcon: ImageView + + private lateinit var description: TextView + private lateinit var descriptionContainer: LinearLayout + private lateinit var descriptionViewMore: TextView + + private lateinit var commentsList: CommentsList + private lateinit var addCommentView: AddCommentView + + private var polycentricProfile: PolycentricProfile? = null + + private lateinit var buttonPolycentric: Button + private lateinit var buttonPlatform: Button + + private var tabIndex: Int? = null + + private var contentOverlayView: View? = null + + lateinit var video: IPlatformVideoDetails + + private lateinit var behavior: BottomSheetBehavior + + private val _taskLoadPolycentricProfile = + TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim( + ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it) + } + + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = + BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + behavior = bottomSheetDialog.behavior + + // TODO figure out how to not need all of these non null assertions + containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! + containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! + containerContentReplies = + bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! + containerContentDescription = + bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! + containerContentSupport = + bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! + + title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! + subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! + channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! + channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! + creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! + channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! + monetization = bottomSheetDialog.findViewById(R.id.monetization)!! + platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! + layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! + textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! + textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! + imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! + imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! + + description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! + descriptionContainer = + bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! + descriptionViewMore = + bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! + + addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! + commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! + buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! + buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! + + commentsList.onAuthorClick.subscribe { c -> + if (c !is PolycentricPlatformComment) { + return@subscribe + } + val id = c.author.id.value + + Logger.i(TAG, "onAuthorClick: $id") + if (id != null && id.startsWith("polycentric://")) { + val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) + mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) + } + } + commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0 + var metadata = "" + if (replyCount > 0) { + metadata += "$replyCount " + requireContext().getString(R.string.replies) + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c + containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { + val newComment = parentComment.cloneWithUpdatedReplyCount( + (parentComment.replyCount ?: 0) + 1 + ) + commentsList.replaceComment(parentComment, newComment) + parentComment = newComment + }) + } else { + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) + } + animateOpenOverlayView(containerContentReplies) + } + + if (StatePolycentric.instance.enabled) { + buttonPolycentric.setOnClickListener { + setTabIndex(0) + StateMeta.instance.setLastCommentSection(0) + } + } else { + buttonPolycentric.visibility = GONE + } + + buttonPlatform.setOnClickListener { + setTabIndex(1) + StateMeta.instance.setLastCommentSection(1) + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + addCommentView.setContext(video.url, ref) + + if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { + setTabIndex(2, true) + } else { + when (Settings.instance.comments.defaultCommentSection) { + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) + 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) + } + } + + containerContentDescription.onClose.subscribe { animateCloseOverlayView() } + containerContentReplies.onClose.subscribe { animateCloseOverlayView() } + + descriptionViewMore.setOnClickListener { + animateOpenOverlayView(containerContentDescription) + } + + updateDescriptionUI(video.description.fixHtmlLinks()) + + val dp5 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) + val dp2 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) + + //UI + title.text = video.name + channelName.text = video.author.name + if (video.author.subscribers != null) { + channelMeta.text = if ((video.author.subscribers + ?: 0) > 0 + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" + (channelName.layoutParams as MarginLayoutParams).setMargins( + 0, (dp5 * -1).toInt(), 0, 0 + ) + } else { + channelMeta.text = "" + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) + } + + video.author.let { + if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) + else monetization.setPlatformMembership(null, null) + } + + val subTitleSegments: ArrayList = ArrayList() + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString( + R.string.watching_now) else requireContext().getString(R.string.views)}") + if (video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0 + val ago = video.datetime?.toHumanNowDiffString(true) + if (diff >= 0) subTitleSegments.add("$ago ago") + else subTitleSegments.add("available in $ago") + } + + platform.setPlatformFromClientID(video.id.pluginId) + subTitle.text = subTitleSegments.joinToString(" • ") + creatorThumbnail.setThumbnail(video.author.thumbnail, false) + + setPolycentricProfile(null, animate = false) + _taskLoadPolycentricProfile.run(video.author.id) + + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = VISIBLE + textDislikes.visibility = VISIBLE + textDislikes.text = r.dislikes.toHumanNumber() + } + + is RatingLikes -> { + val r = video.rating as RatingLikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = GONE + textDislikes.visibility = GONE + } + + else -> { + layoutRating.visibility = GONE + } + } + + monetization.onSupportTap.subscribe { + containerContentSupport.setPolycentricProfile(polycentricProfile) + animateOpenOverlayView(containerContentSupport) + } + + monetization.onStoreTap.subscribe { + polycentricProfile?.systemState?.store?.let { + try { + val uri = it.toUri() + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + requireContext().startActivity(intent) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e) + } + } + } + monetization.onUrlTap.subscribe { + mainFragment!!.navigate(it) + } + + addCommentView.onCommentAdded.subscribe { + commentsList.addComment(it) + } + + channelButton.setOnClickListener { + mainFragment!!.navigate(video.author) + } + + return bottomSheetDialog + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + animateCloseOverlayView() + } + + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + polycentricProfile = profile + + val dp35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } + + if (avatar != null) { + creatorThumbnail.setThumbnail(avatar, animate) + } else { + creatorThumbnail.setThumbnail(video.author.thumbnail, animate) + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) + } + + val username = profile?.systemState?.username + if (username != null) { + channelName.text = username + } + + monetization.setPolycentricProfile(profile) + } + + private fun setTabIndex(index: Int?, forceReload: Boolean = false) { + Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") + val changed = tabIndex != index || forceReload + if (!changed) { + return + } + + tabIndex = index + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) + + when (index) { + null -> { + addCommentView.visibility = GONE + commentsList.clear() + } + + 0 -> { + addCommentView.visibility = VISIBLE + fetchPolycentricComments() + } + + 1 -> { + addCommentView.visibility = GONE + fetchComments() + } + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video.let { + commentsList.load(true) { StatePlatform.instance.getComments(it) } + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video + val idValue = video.id.value + if (video.url.isEmpty()) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was null") + commentsList.clear() + return + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } + } + + private fun updateDescriptionUI(text: Spanned) { + containerContentDescription.load(text) + description.text = text + + if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE + else descriptionContainer.visibility = GONE + } + + private fun animateOpenOverlayView(view: View) { + if (contentOverlayView != null) { + Logger.e(TAG, "Content overlay already open") + return + } + + behavior.isDraggable = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + val animHeight = containerContentMain.height + + view.translationY = animHeight.toFloat() + view.visibility = VISIBLE + + view.animate().setDuration(300).translationY(0f).withEndAction { + contentOverlayView = view + }.start() + } + + private fun animateCloseOverlayView() { + val curView = contentOverlayView + if (curView == null) { + Logger.e(TAG, "No content overlay open") + return + } + + behavior.isDraggable = true + + val animHeight = contentOverlayView!!.height + + curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { + curView.visibility = GONE + contentOverlayView = null + }.start() + } + + companion object { + const val TAG = "ModalBottomSheet" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index cba656dd..972ce336 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -500,7 +500,7 @@ class StatePlatform { .toList() .associateWith { 1f }; - val pager = MultiDistributionContentPager(pages); + val pager = MultiDistributionContentPager(pages, 2); pager.initialize(); return pager; } diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/ShortsButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/ShortsButton.kt new file mode 100644 index 00000000..68ba715b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/ShortsButton.kt @@ -0,0 +1,117 @@ +package com.futo.platformplayer.views.buttons + +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.constructs.Event0 +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.ShapeAppearanceModel + +class ShortsButton : LinearLayout { + private val _root: LinearLayout; + private val _icon: ImageView; + private val _textPrimary: TextView; + val onClick = Event0(); + + var iconId: Int? = null; + + constructor(context : Context, text: String, icon: Int, action: ()->Unit) : super(context) { + inflate(context, R.layout.view_shorts_button, this); + _icon = findViewById(R.id.button_icon); + _textPrimary = findViewById(R.id.button_text); + _root = findViewById(R.id.root); + + withPrimaryText(text); + withIcon(icon); + + _root.apply { + isClickable = true; + setOnClickListener { + if(!isEnabled) + return@setOnClickListener; + action(); + onClick.emit(); + UIDialogs.toast("Clicked button: " + _textPrimary.text); + }; + } + } + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_shorts_button, this); + _icon = findViewById(R.id.image_icon); + _textPrimary = findViewById(R.id.text_title); + _root = findViewById(R.id.root); + _root.apply { + isClickable = true; + setOnClickListener { + if(!isEnabled) + return@setOnClickListener; + onClick.emit(); + }; + } + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.ShortsButton, 0, 0); + val attrIconRef = attrArr.getResourceId(R.styleable.ShortsButton_buttonIcon_s, -1); + val attrText = attrArr.getText(R.styleable.ShortsButton_buttonText_s) ?: ""; + attrArr.recycle() + + withIcon(attrIconRef); + withPrimaryText(attrText.toString()); + } + + fun withMargin(bottom: Int, side: Int = 0): ShortsButton { + setPadding(side, 0, side, bottom) + return this; + } + fun withPrimaryText(text: String): ShortsButton { + _textPrimary.text = text; + + if(text.isNullOrBlank()) + _textPrimary.visibility = View.GONE; + else + _textPrimary.visibility = View.VISIBLE; + return this; + } + + fun withIcon(resourceId: Int): ShortsButton { + if (resourceId != -1) { + _icon.visibility = View.VISIBLE; + _icon.setImageResource(resourceId); + } else + _icon.visibility = View.GONE; + _icon.scaleType = ImageView.ScaleType.CENTER_CROP; + iconId = resourceId; + + return this; + } + + + fun withIcon(bitmap: Bitmap): ShortsButton { + _icon.visibility = View.VISIBLE; + _icon.setImageBitmap(bitmap); + iconId = -1; + + _icon.scaleType = ImageView.ScaleType.CENTER_CROP; + + return this; + } + + fun setButtonEnabled(enabled: Boolean) { + if(enabled) { + alpha = 1f; + isEnabled = true; + isClickable = true; + } + else { + alpha = 0.5f; + isEnabled = false; + isClickable = false; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index d655d7dd..9ea6819f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -14,6 +14,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.getDataLinkFromUrl import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.views.IdenticonView import userpackage.Protocol @@ -82,14 +83,14 @@ class CreatorThumbnail : ConstraintLayout { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) - .diskCacheStrategy(DiskCacheStrategy.DATA) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) .crossfade() - .into(_imageChannelThumbnail); + .into(_imageChannelThumbnail) } else { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) - .diskCacheStrategy(DiskCacheStrategy.DATA) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) .into(_imageChannelThumbnail); } } diff --git a/app/src/main/res/drawable/ic_comment_s.xml b/app/src/main/res/drawable/ic_comment_s.xml new file mode 100644 index 00000000..6fdc655f --- /dev/null +++ b/app/src/main/res/drawable/ic_comment_s.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_s.xml b/app/src/main/res/drawable/ic_settings_s.xml new file mode 100644 index 00000000..0ef3317e --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_s.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_s.xml b/app/src/main/res/drawable/ic_share_s.xml new file mode 100644 index 00000000..9d814197 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_s.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down_s.xml b/app/src/main/res/drawable/ic_thumb_down_s.xml new file mode 100644 index 00000000..aa4228fa --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_s.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down_s_filled.xml b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml new file mode 100644 index 00000000..96f55911 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_up_s.xml b/app/src/main/res/drawable/ic_thumb_up_s.xml new file mode 100644 index 00000000..2a0d62e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_s.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_up_s_filled.xml b/app/src/main/res/drawable/ic_thumb_up_s_filled.xml new file mode 100644 index 00000000..a029aca4 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_s_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/view_short_overlay.xml b/app/src/main/res/layout/view_short_overlay.xml index b55e49e0..3d961c6b 100644 --- a/app/src/main/res/layout/view_short_overlay.xml +++ b/app/src/main/res/layout/view_short_overlay.xml @@ -129,6 +129,19 @@ android:text="" android:textColor="@android:color/white" android:textSize="14sp" /> + @@ -143,341 +156,88 @@ app:layout_constraintEnd_toEndOf="parent"> - - - - - - - - - - - + android:layout_marginBottom="10dp" + android:checkable="true" + android:contentDescription="@string/cd_image_like_icon" + app:backgroundTint="@color/transparent" + app:buttonIcon_s="@drawable/ic_thumb_up_s" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:rippleColor="@color/ripple" + app:toggleCheckedStateOnClick="false" /> - - - - - - - - - - - + android:layout_marginBottom="20dp" + android:checkable="true" + android:contentDescription="@string/cd_image_dislike_icon" + app:backgroundTint="@color/transparent" + app:buttonIcon_s="@drawable/ic_thumb_down_s" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:rippleColor="@color/ripple" + app:toggleCheckedStateOnClick="false" /> - - - - - - - - - - - + android:layout_marginBottom="20dp" + android:contentDescription="@string/comments" + app:buttonIcon_s="@drawable/ic_comment_s" + app:buttonText_s="" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:rippleColor="@color/ripple" /> - - - - - - - - - - - + android:layout_marginBottom="20dp" + android:contentDescription="@string/share" + app:buttonIcon_s="@drawable/ic_share_s" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:rippleColor="@color/ripple" /> - - - - - - - - - - - + android:layout_marginBottom="20dp" + android:contentDescription="@string/refresh" + app:buttonIcon_s="@drawable/ic_refresh" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:rippleColor="@color/ripple" /> - - - - - - - - - - - + android:layout_marginBottom="10dp" + android:contentDescription="@string/quality" + app:buttonIcon_s="@drawable/ic_settings_s" + app:iconSize="24dp" + app:iconTint="@android:color/white" + app:rippleColor="@color/ripple" /> diff --git a/app/src/main/res/layout/view_short_player.xml b/app/src/main/res/layout/view_short_player.xml index c067de8c..ca3f7c70 100644 --- a/app/src/main/res/layout/view_short_player.xml +++ b/app/src/main/res/layout/view_short_player.xml @@ -9,7 +9,7 @@ android:layout_above="@+id/short_player_progress_bar" android:background="@color/black" app:default_artwork="@drawable/placeholder_video_thumbnail" - app:resize_mode="fit" + app:resize_mode="fill" app:show_buffering="when_playing" app:use_artwork="true" app:use_controller="false" /> @@ -17,9 +17,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/shorts_button_attrs.xml b/app/src/main/res/values/shorts_button_attrs.xml new file mode 100644 index 00000000..de2738e0 --- /dev/null +++ b/app/src/main/res/values/shorts_button_attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts index 089987f0..8cff240c 160000 --- a/app/src/stable/assets/sources/apple-podcasts +++ b/app/src/stable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 089987f007319cf22972090a0cb09afd8c008adb +Subproject commit 8cff240ca7e9089ab26c03f78b6104d9cc2162fe diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index b7173f15..4ff0b027 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit b7173f1538a8259ace0c606dfc3441426a659536 +Subproject commit 4ff0b02700fb5d52fe5bf4cf9bb379d21f6b6853 diff --git a/app/src/stable/assets/sources/peertube b/app/src/stable/assets/sources/peertube index 56bff391..21dcf4be 160000 --- a/app/src/stable/assets/sources/peertube +++ b/app/src/stable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 56bff391239e676e7d347ad3730df17795938a7b +Subproject commit 21dcf4bef5847898752a7856b1208456a3031b6d diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index 401274b1..3368dfaa 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 401274b1ec2806b3a61f877080ff023b4bf5dc0d +Subproject commit 3368dfaa2ccaaa060cbbc0e91c86200e4d927b6e diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 8c0f03f5..207738f5 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 8c0f03f5fbc9b4e499437b85c757ec40cb7c0126 +Subproject commit 207738f5997f52219c150d2122bd068c6aed2970 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 2b724f21..95c60c2d 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 2b724f21a727c3fefe16adb38f06aa8730b1b8ec +Subproject commit 95c60c2dc6aad556bb6fcc1e55795a412ac96340 diff --git a/app/src/unstable/assets/sources/apple-podcasts b/app/src/unstable/assets/sources/apple-podcasts index 089987f0..8cff240c 160000 --- a/app/src/unstable/assets/sources/apple-podcasts +++ b/app/src/unstable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 089987f007319cf22972090a0cb09afd8c008adb +Subproject commit 8cff240ca7e9089ab26c03f78b6104d9cc2162fe diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index b7173f15..4ff0b027 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit b7173f1538a8259ace0c606dfc3441426a659536 +Subproject commit 4ff0b02700fb5d52fe5bf4cf9bb379d21f6b6853 diff --git a/app/src/unstable/assets/sources/peertube b/app/src/unstable/assets/sources/peertube index 56bff391..21dcf4be 160000 --- a/app/src/unstable/assets/sources/peertube +++ b/app/src/unstable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 56bff391239e676e7d347ad3730df17795938a7b +Subproject commit 21dcf4bef5847898752a7856b1208456a3031b6d diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index 401274b1..3368dfaa 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 401274b1ec2806b3a61f877080ff023b4bf5dc0d +Subproject commit 3368dfaa2ccaaa060cbbc0e91c86200e4d927b6e diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 8c0f03f5..207738f5 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 8c0f03f5fbc9b4e499437b85c757ec40cb7c0126 +Subproject commit 207738f5997f52219c150d2122bd068c6aed2970 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 2b724f21..95c60c2d 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 2b724f21a727c3fefe16adb38f06aa8730b1b8ec +Subproject commit 95c60c2dc6aad556bb6fcc1e55795a412ac96340 From dc76934d0eb93fdb6d3e0d4b8788c275c56b965a Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 12 Aug 2025 17:05:54 +0200 Subject: [PATCH 12/46] Add explicit long type for dash dwonload length --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 23ace157..2d0bc47b 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -719,7 +719,7 @@ class VideoDownload { Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString()); - var written = 0; + var written: Long = 0; var indexCounter = 0; onProgress(foundCues.count().toLong(), 0, 0); for(cue in foundCues) { @@ -744,7 +744,7 @@ class VideoDownload { indexCounter++; } - sourceLength = written.toLong(); + sourceLength = written; Logger.i(TAG, "$name downloadSource Finished"); } From 3909343adcc0fff648addf542e4b127284151092 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 13 Aug 2025 00:23:54 +0200 Subject: [PATCH 13/46] Pre-generate support shorts, subtitle size, short like/dislike color --- .../java/com/futo/platformplayer/Settings.kt | 5 +++++ .../sources/JSDashManifestRawAudioSource.kt | 13 ++++++++++++ .../models/sources/JSDashManifestRawSource.kt | 12 +++++++++++ .../fragment/mainactivity/main/ShortView.kt | 21 +++++++++++++++++++ .../views/video/FutoShortPlayer.kt | 3 +++ .../res/drawable/ic_thumb_down_s_filled.xml | 2 +- .../res/drawable/ic_thumb_up_s_filled.xml | 5 ++--- app/src/main/res/values/strings.xml | 2 ++ 8 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index da414d8f..360a281c 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -603,6 +603,11 @@ class Settings : FragmentedStorageFileJson() { else -> 2.0 } } + + + @AdvancedField + @FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28) + var shortsPregenerate: Boolean = false; } @FormField(R.string.comments, "group", R.string.comments_description, 6) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index 7b6388cd..f376ee6e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -17,6 +17,7 @@ import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8Async +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.others.Language import com.futo.platformplayer.states.StateDeveloper import kotlinx.coroutines.CompletableDeferred @@ -57,12 +58,24 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS hasGenerate = _obj.has("generate"); } + private var _pregenerate: V8Deferred? = null; + fun pregenerateAsync(scope: CoroutineScope): V8Deferred? { + _pregenerate = generateAsync(scope); + return _pregenerate; + } + override fun generateAsync(scope: CoroutineScope): V8Deferred { if(!hasGenerate) return V8Deferred(CompletableDeferred(manifest)); if(_obj.isClosed) throw IllegalStateException("Source object already closed"); + val pregenerated = _pregenerate; + if(pregenerated != null) { + Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio"); + return pregenerated; + } + val plugin = _plugin.getUnderlyingPlugin(); var result: V8Deferred? = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt index aebaab23..9345b174 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8Async +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -65,11 +66,22 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo hasGenerate = _obj.has("generate"); } + private var _pregenerate: V8Deferred? = null; + fun pregenerateAsync(scope: CoroutineScope): V8Deferred? { + _pregenerate = generateAsync(scope); + return _pregenerate; + } + override fun generateAsync(scope: CoroutineScope): V8Deferred { if(!hasGenerate) return V8Deferred(CompletableDeferred(manifest)); if(_obj.isClosed) throw IllegalStateException("Source object already closed"); + val pregenerated = _pregenerate; + if(pregenerated != null) { + Logger.w("JSDashManifestRawSource", "Returning pre-generated video"); + return pregenerated; + } val plugin = _plugin.getUnderlyingPlugin(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt index 2ec3d993..7078fd5d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -35,6 +35,8 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event3 @@ -66,6 +68,8 @@ import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.video.FutoShortPlayer import com.futo.platformplayer.views.video.FutoVideoPlayerBase +import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS +import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models @@ -742,6 +746,23 @@ class ShortView : FrameLayout { videoDetails = result video = result + if(Settings.instance.playback.shortsPregenerate) + fragment.lifecycleScope.launch(Dispatchers.IO) { + if(result != null) { + val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS); + val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context)); + + if(prefVid != null && prefVid is JSDashManifestRawSource) { + Logger.i(TAG, "Shorts pregenerating video (${result.name})"); + prefVid.pregenerateAsync(fragment.lifecycleScope); + } + if(prefAud != null && prefAud is JSDashManifestRawAudioSource) { + Logger.i(TAG, "Shorts pregenerating audio (${result.name})"); + prefAud.pregenerateAsync(fragment.lifecycleScope); + } + } + } + bottomSheet.video = result setLoading(false) diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt index cb5f1240..0b74781d 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -6,6 +6,7 @@ import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.animation.LinearInterpolator +import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -65,6 +66,8 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : videoView = findViewById(R.id.short_player_view) progressBar = findViewById(R.id.short_player_progress_bar) + videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F); + if (!isInEditMode) { player = StatePlayer.instance.getShortPlayerOrCreate(context) player.player.repeatMode = Player.REPEAT_MODE_ONE diff --git a/app/src/main/res/drawable/ic_thumb_down_s_filled.xml b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml index 96f55911..6dbc120c 100644 --- a/app/src/main/res/drawable/ic_thumb_down_s_filled.xml +++ b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml @@ -5,6 +5,6 @@ android:viewportHeight="960" android:tint="?attr/colorControlNormal"> diff --git a/app/src/main/res/drawable/ic_thumb_up_s_filled.xml b/app/src/main/res/drawable/ic_thumb_up_s_filled.xml index a029aca4..ba1ac372 100644 --- a/app/src/main/res/drawable/ic_thumb_up_s_filled.xml +++ b/app/src/main/res/drawable/ic_thumb_up_s_filled.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="960" - android:viewportHeight="960" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="960"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ddeef06..6a7a30a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -435,6 +435,8 @@ Allow full-screen portrait when watching horizontal videos Delete from WatchLater when watched After you leave a video that you mostly watched, it will be removed from watch later. + Pre-generate shorts sources + Generates short sources (when applicable) one video ahead Seek duration Minimum Playback Speed Minimum Available Speed From e080702a52f9d8b311dc2a9bc54227efb68c5064 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 13 Aug 2025 17:56:27 +0200 Subject: [PATCH 14/46] Fix dislike color --- app/src/main/res/drawable/ic_thumb_down_s_filled.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/res/drawable/ic_thumb_down_s_filled.xml b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml index 6dbc120c..d3e4bcde 100644 --- a/app/src/main/res/drawable/ic_thumb_down_s_filled.xml +++ b/app/src/main/res/drawable/ic_thumb_down_s_filled.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="960" - android:viewportHeight="960" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="960"> From 5247997ea554b0620ef54d5552e57111279e8b77 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 13 Aug 2025 19:36:26 +0200 Subject: [PATCH 15/46] Set plugin install request timeouts, fix messaging surrounding downloading icons --- .../java/com/futo/platformplayer/states/StatePlugins.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index cbb7b4d4..7e1f8ba4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -475,6 +475,7 @@ class StatePlugins { delay(500); val client = ManagedHttpClient(); + client.setTimeout(10000); try { withContext(Dispatchers.Main) { onProgress.invoke("Validating script", 0.25); @@ -489,14 +490,14 @@ class StatePlugins { } val icon = config.absoluteIconUrl?.let { absIconUrl -> - withContext(Dispatchers.Main) { - onProgress.invoke("Saving plugin", 0.75); - } val iconResp = client.get(absIconUrl); if (iconResp.isOk) return@let iconResp.body?.byteStream()?.use { it.readBytes() }; return@let null; } + withContext(Dispatchers.Main) { + onProgress.invoke("Saving plugin", 0.75); + } val installEx = StatePlugins.instance.createPlugin(config, script, icon, true); if (installEx != null) throw installEx; From a0f4cc760c80358d4254122e65e4b8614ce56041 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 14 Aug 2025 12:35:46 +0200 Subject: [PATCH 16/46] Fix background play, disable artwork on background till improved, renamed variable that caused confusion --- .../mainactivity/main/VideoDetailFragment.kt | 6 ++-- .../mainactivity/main/VideoDetailView.kt | 31 +++++++++---------- .../views/video/FutoVideoPlayer.kt | 6 +++- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 304f52b2..af228bf7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -437,7 +437,7 @@ class VideoDetailFragment() : MainFragment() { fun onUserLeaveHint() { val viewDetail = _viewDetail; - Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}"); + Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}"); if (viewDetail === null) { return @@ -446,7 +446,7 @@ class VideoDetailFragment() : MainFragment() { if (viewDetail.shouldEnterPictureInPicture) { _leavingPiP = false } - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") @@ -526,7 +526,7 @@ class VideoDetailFragment() : MainFragment() { private fun stopIfRequired() { var shouldStop = true; - if (_viewDetail?.allowBackground == true) { + if (_viewDetail?.isAudioOnlyUserAction == true) { shouldStop = false; } else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) { shouldStop = false; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 5382ef13..09e7b20d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.Rect import android.graphics.drawable.Animatable import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable @@ -51,7 +50,6 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -82,7 +80,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.platforms.js.models.JSVideo import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager @@ -326,7 +323,7 @@ class VideoDetailView : ConstraintLayout { val onEnterPictureInPicture = Event0(); val onVideoChanged = Event2() - var allowBackground: Boolean = false + var isAudioOnlyUserAction: Boolean = false private set(value) { if (field != value) { field = value @@ -338,7 +335,7 @@ class VideoDetailView : ConstraintLayout { get() = !preventPictureInPicture && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && - !allowBackground && + !isAudioOnlyUserAction && isPlaying val onShouldEnterPictureInPictureChanged = Event0(); @@ -764,7 +761,7 @@ class VideoDetailView : ConstraintLayout { MediaControlReceiver.onBackgroundReceived.subscribe(this) { Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") _player.switchToAudioMode(video); - allowBackground = true; + isAudioOnlyUserAction = true; StateApp.instance.contextOrNull?.let { try { if (it is MainActivity) { @@ -1009,14 +1006,14 @@ class VideoDetailView : ConstraintLayout { } _slideUpOverlay?.hide(); } else null, - if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { - if (!allowBackground) { + if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { + if (!isAudioOnlyUserAction) { _player.switchToAudioMode(video); - allowBackground = true; + isAudioOnlyUserAction = true; it.text.text = resources.getString(R.string.background_revert); } else { _player.switchToVideoMode(); - allowBackground = false; + isAudioOnlyUserAction = false; it.text.text = resources.getString(R.string.background); } _slideUpOverlay?.hide(); @@ -1156,11 +1153,14 @@ class VideoDetailView : ConstraintLayout { if(_player.isAudioMode) { //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? - if(!allowBackground) { + if(!isAudioOnlyUserAction) { _player.switchToVideoMode(); - allowBackground = false; + isAudioOnlyUserAction = false; _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); } + else { + _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video); + } } if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture) _player.fitHeight(); @@ -1176,7 +1176,7 @@ class VideoDetailView : ConstraintLayout { if(StateCasting.instance.isCasting) return; - if(allowBackground) + if(isAudioOnlyUserAction) StatePlayer.instance.startOrUpdateMediaSession(context, video); else { when (Settings.instance.playback.backgroundPlay) { @@ -1184,7 +1184,6 @@ class VideoDetailView : ConstraintLayout { 1 -> { if(!(video?.isLive ?: false)) { _player.switchToAudioMode(video); - allowBackground = true; } StatePlayer.instance.startOrUpdateMediaSession(context, video); } @@ -1971,10 +1970,10 @@ class VideoDetailView : ConstraintLayout { if (isLimitedVersion && _player.isAudioMode) { _player.switchToVideoMode() - allowBackground = false; + isAudioOnlyUserAction = false; } else { val thumbnail = video.thumbnails.getHQThumbnail(); - if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) + if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode Glide.with(context).asBitmap().load(thumbnail) .into(object: CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index f6432752..c71b8239 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -907,11 +907,14 @@ class FutoVideoPlayer : FutoVideoPlayerBase { override fun switchToVideoMode() { super.switchToVideoMode() - setArtwork(null) + //setArtwork(null) } override fun switchToAudioMode(video: IPlatformVideoDetails?) { super.switchToAudioMode(video) + + //This causes issues, and is in general confusing, needs improvements + /* val thumbnail = video?.thumbnails?.getHQThumbnail() if (!thumbnail.isNullOrBlank()) { Glide.with(context).asBitmap().load(thumbnail) @@ -928,5 +931,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } }) } + */ } } \ No newline at end of file From 8569eaa5db9936470732e5965ae95469763dfa67 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 14 Aug 2025 20:36:56 +0200 Subject: [PATCH 17/46] Hide DevSubmit filter --- .../futo/platformplayer/engine/packages/PackageBridge.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index db44c1fc..038c734d 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -194,7 +194,11 @@ class PackageBridge : V8Package { val stackTrace = Thread.currentThread().stackTrace; val callerMethod = stackTrace.findLast { - it.className == JSClient::class.java.name + it.className == JSClient::class.java.name && + it.methodName != "isBusy" && + it.methodName != "busy" && + it.methodName != "getCopy" && + it.methodName != "isBusyWith" }?.methodName ?: ""; val session = StateApp.instance.sessionId; val pluginId = _plugin.config.id; From 17027ba364e98ef21c65c7872cb105a953eacbf8 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 14 Aug 2025 21:03:39 +0200 Subject: [PATCH 18/46] Remote history sync on toggle --- .../mainactivity/main/SourceDetailFragment.kt | 42 ++++++++++++++++++- .../platformplayer/states/StateHistory.kt | 9 ++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt index 5c2a8085..0452395f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDeveloper +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.views.buttons.BigButton @@ -152,11 +153,50 @@ class SourceDetailFragment : MainFragment() { if(field is View) field.isVisible = false; } - if(!source.capabilities.hasGetUserHistory) { + if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) { val field = _settingsAppForm.findField("sync"); if(field is View) field.isVisible = false; } + else { + val field = _settingsAppForm.findField("syncHistory"); + field?.onChanged?.subscribe { field, new, old -> + if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) { + UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?", + "This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0, + UIDialogs.Action("No", { + + }), + UIDialogs.Action("Yes", { + UIDialogs.showDialogProgress(context, { + it.setText("Importing history.."); + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val client = StatePlatform.instance.getClient(config.id); + if (client != null && client is JSClient) { + val count = StateHistory.instance.syncRemoteHistory(client); + withContext(Dispatchers.Main) { + it.hide(); + if(count > 0) + UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items"); + else + UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items"); + } + + } + } + catch(ex: Throwable) { + withContext(Dispatchers.Main) { + UIDialogs.appToast("Sync History failed due to:\n" + ex.message); + it.hide(); + } + } + } + }); + }, UIDialogs.ActionStyle.PRIMARY)); + } + } + } _settingsAppForm.onChanged.clear(); _settingsAppForm.onChanged.subscribe { field, value -> _settingsAppChanged = true; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt index f3bcca55..26fc3170 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -194,17 +194,18 @@ class StateHistory { _remoteHistoryDatesStore.save(); } - fun syncRemoteHistory(plugin: JSClient) { + fun syncRemoteHistory(plugin: JSClient): Int { if (plugin.capabilities.hasGetUserHistory && plugin.isLoggedIn) { Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]"); val hist = StatePlatform.instance.getUserHistory(plugin.id); - syncRemoteHistory(plugin.id, hist, 100, 3); + return syncRemoteHistory(plugin.id, hist, 100, 3); } + return 0; } - fun syncRemoteHistory(pluginId: String, videos: IPager, maxVideos: Int, maxPages: Int) { + fun syncRemoteHistory(pluginId: String, videos: IPager, maxVideos: Int, maxPages: Int): Int { val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN; val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos; val maxPageCount = if(maxPages <= 0) 3 else maxPages; @@ -272,12 +273,14 @@ class StateHistory { } catch(ex: Throwable){} } + return updated; } } catch(ex: Throwable) { val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null; Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message) } + return 0; } companion object { From 91060faac91f92b90e4dbd3e92c95a58e882a83b Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 15 Aug 2025 16:36:38 +0200 Subject: [PATCH 19/46] VOD chat --- .../fragment/mainactivity/main/VideoDetailView.kt | 13 +++++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 14 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index e9765a3b..3d6a29e4 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1006,6 +1006,18 @@ class VideoDetailView : ConstraintLayout { } _slideUpOverlay?.hide(); } else null, + if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents()) + RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) { + video?.let { + try { + loadVODChat(it); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to reopen vod chat", ex); + } + } + _slideUpOverlay?.hide(); + } else null, if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { if (!isAudioOnlyUserAction) { _player.switchToAudioMode(video); @@ -3443,6 +3455,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_SHARE = "share"; const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; + const val TAG_VODCHAT = "vodchat"; const val TAG_CHAPTERS = "chapters"; const val TAG_OPEN = "open"; const val TAG_SEND_TO_DEVICE = "send_to_device"; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a7a30a3..f387e1f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -247,6 +247,7 @@ Membership Store Live Chat + VOD Chat Remove Videos Playlist From 1edc8aabf808fadfa141969bdec3f8999358ab93 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 15 Aug 2025 21:20:23 +0200 Subject: [PATCH 20/46] Fix login dialog --- .../fragment/mainactivity/main/VideoDetailView.kt | 15 ++++++++++++--- .../futo/platformplayer/states/StatePlugins.kt | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 3d6a29e4..006da03a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1141,10 +1141,14 @@ class VideoDetailView : ConstraintLayout { //Lifecycle + var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video) fun onResume() { Logger.v(TAG, "onResume"); _onPauseCalled = false; + val wasLoginCall = isLoginStop; + isLoginStop = false; + Logger.i(TAG, "_video: ${video?.name ?: "no video"}"); Logger.i(TAG, "_didStop: $_didStop"); @@ -1153,7 +1157,7 @@ class VideoDetailView : ConstraintLayout { val t = (lastPositionMilliseconds / 1000.0f).roundToLong(); if(_searchVideo != null) setVideoOverview(_searchVideo!!, true, t); - else if(_url != null) + else if(_url != null && !wasLoginCall) setVideo(_url!!, t, _playWhenReady); } else if(_didStop) { @@ -3276,8 +3280,13 @@ class VideoDetailView : ConstraintLayout { val id = e.config.let { if(it is SourcePluginConfig) it.id else null }; val didLogin = if(id == null) false - else StatePlugins.instance.loginPlugin(context, id) { - fetchVideo(); + else { + isLoginStop = true; + StatePlugins.instance.loginPlugin(context, id) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + fetchVideo(); + } + } } if(!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login"); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index 7e1f8ba4..e80a2df3 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -179,8 +179,9 @@ class StatePlugins { } StateApp.instance.scope.launch(Dispatchers.IO) { - StatePlatform.instance.reloadClient(context, id); - afterLogin.invoke(); + StatePlatform.instance.reloadClient(context, id) { + afterLogin.invoke(); + } } }; return true; From ca3454afbea966babf19bfb333766a66468361c1 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 18 Aug 2025 19:35:12 +0200 Subject: [PATCH 21/46] Login warning fixes, uimod (disabled) --- .../activities/LoginActivity.kt | 37 ++++++++++++++++++- .../platforms/js/SourcePluginAuthConfig.kt | 20 +++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt index 1dc4376e..f072c549 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -76,6 +76,10 @@ class LoginActivity : AppCompatActivity() { }; var isFirstLoad = true; val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf(); + val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf(); + var currentScale = 100; + var currentDesktop = false; + _webView.setInitialScale(50); webViewClient.onPageLoaded.subscribe { view, url -> _textUrl.setText(url ?: ""); @@ -89,9 +93,9 @@ class LoginActivity : AppCompatActivity() { view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); } - if(loginWarnings.size > 0) { + if(loginWarnings.size > 0 && url != null) { synchronized(loginWarnings) { - val warning = loginWarnings.find { it.url.matches(it.getRegex()) }; + val warning = loginWarnings.find { url.matches(it.getRegex()) }; if(warning != null) { if(warning.once == true) loginWarnings.remove(warning); @@ -101,6 +105,35 @@ class LoginActivity : AppCompatActivity() { } } } + + /* + var specifiedScale = false; + var specifiedDesktop = false; + if(uiMods.size > 0 && url != null) { + synchronized(uiMods) { + val uimod = uiMods.find { url.matches(it.getRegex()) }; + if(uimod != null) { + if(uimod.scale != null) { + currentScale =(uimod.scale * 100).toInt(); + _webView.setInitialScale(currentScale); + specifiedScale = true; + } + if(uimod.desktop != null && uimod.desktop) { + _webView.settings.useWideViewPort = true; + specifiedDesktop = true; + } + } + } + } + if(!specifiedScale && currentScale != 100) { + currentScale = (100).toInt(); + _webView.setInitialScale(currentScale); + } + if(!specifiedDesktop && currentDesktop) { + _webView.settings.useWideViewPort = false; + currentDesktop = false; + } + */ } _webView.settings.domStorageEnabled = true; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt index 8821c79e..4a05a720 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt @@ -16,7 +16,8 @@ class SourcePluginAuthConfig( val loginButton: String? = null, val domainHeadersToFind: Map>? = null, val loginWarning: String? = null, - val loginWarnings: List? = null + val loginWarnings: List? = null, + val uiMods: List? = null ) { @Serializable @@ -29,6 +30,23 @@ class SourcePluginAuthConfig( @Contextual private var _regex: Regex? = null; + fun getRegex(): Regex { + return _regex ?: url.let { + val reg = Regex(it); + _regex = reg; + return reg; + } + } + } + @Serializable + class UIMod( + val url: String, + val scale: Float?, + val desktop: Boolean? + ) { + @Contextual + private var _regex: Regex? = null; + fun getRegex(): Regex { return _regex ?: url.let { val reg = Regex(it); From 43ec7e821b1a063d13100f0c0c403877ccb936e6 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 18 Aug 2025 21:31:49 +0200 Subject: [PATCH 22/46] Refs --- app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 12226380..0fc4e8fb 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 12226380428664a1de75abd2886ae12e00ec691f +Subproject commit 0fc4e8fbbf86eb1e53d17b40d2a0ac5f28a69905 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 207738f5..092faa64 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 207738f5997f52219c150d2122bd068c6aed2970 +Subproject commit 092faa64d59105284745d88e69017f9a3e27f01d diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 95c60c2d..bd6fbe8d 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 95c60c2dc6aad556bb6fcc1e55795a412ac96340 +Subproject commit bd6fbe8d4994013515a530570d7346635f8613c6 diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 12226380..0fc4e8fb 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 12226380428664a1de75abd2886ae12e00ec691f +Subproject commit 0fc4e8fbbf86eb1e53d17b40d2a0ac5f28a69905 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 207738f5..092faa64 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 207738f5997f52219c150d2122bd068c6aed2970 +Subproject commit 092faa64d59105284745d88e69017f9a3e27f01d diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 95c60c2d..bd6fbe8d 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 95c60c2dc6aad556bb6fcc1e55795a412ac96340 +Subproject commit bd6fbe8d4994013515a530570d7346635f8613c6 From 9481bbf3f14e8f084ba38e4d37d4ce918edb9ef9 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 19 Aug 2025 16:42:17 +0200 Subject: [PATCH 23/46] Vod chat button fix, default settings in devportal --- app/src/main/assets/devportal/index.html | 24 +++++++++++++++++-- .../mainactivity/main/VideoDetailView.kt | 3 +-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/src/main/assets/devportal/index.html b/app/src/main/assets/devportal/index.html index 9ae84f5a..47bf94c7 100644 --- a/app/src/main/assets/devportal/index.html +++ b/app/src/main/assets/devportal/index.html @@ -1022,15 +1022,35 @@ return x.value }); + + let settingsToUse = __DEV_SETTINGS ?? {}; + if (true) { + for (let setting of this.Plugin?.currentPlugin?.settings) { + if (typeof settingsToUse[setting.variable] == "undefined") { + switch (setting?.type?.toLowerCase()) { + case "boolean": + settingsToUse[setting.variable] = setting.default === 'true'; + break; + case "dropdown": + let dropDownIndex = parseInt(setting.default); + if (dropDownIndex) { + settingsToUse[setting.variable] = setting.options[dropDownIndex]; + } + break; + } + } + } + } + if(name == "enable") { if(parameterVals.length > 0) parameterVals[0] = this.Plugin.currentPlugin; else parameterVals.push(this.Plugin.currentPlugin); if(parameterVals.length > 1) - parameterVals[1] = __DEV_SETTINGS; + parameterVals[1] = settingsToUse; else - parameterVals.push(__DEV_SETTINGS); + parameterVals.push(settingsToUse); } const func = source[name]; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 006da03a..bc5b9a49 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1005,8 +1005,7 @@ class VideoDetailView : ConstraintLayout { } } _slideUpOverlay?.hide(); - } else null, - if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents()) + } else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents()) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) { video?.let { try { From b7c123c281f8166131728e4dcaedd4e46470bac4 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 19 Aug 2025 16:45:57 +0200 Subject: [PATCH 24/46] Refs --- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 092faa64..0b50c2e6 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 092faa64d59105284745d88e69017f9a3e27f01d +Subproject commit 0b50c2e61b6b497f2633e866370daf5cfc51f351 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index bd6fbe8d..91d3b683 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit bd6fbe8d4994013515a530570d7346635f8613c6 +Subproject commit 91d3b68324b27a996f08deadfe961e3935344ef8 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 092faa64..0b50c2e6 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 092faa64d59105284745d88e69017f9a3e27f01d +Subproject commit 0b50c2e61b6b497f2633e866370daf5cfc51f351 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index bd6fbe8d..91d3b683 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit bd6fbe8d4994013515a530570d7346635f8613c6 +Subproject commit 91d3b68324b27a996f08deadfe961e3935344ef8 From 66f87110552d773dda6e21c5c4f5654797161dae Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 19 Aug 2025 17:52:14 +0200 Subject: [PATCH 25/46] Fix login warnings working on redirects --- .../activities/LoginActivity.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt index f072c549..80062a24 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -79,20 +79,9 @@ class LoginActivity : AppCompatActivity() { val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf(); var currentScale = 100; var currentDesktop = false; - _webView.setInitialScale(50); webViewClient.onPageLoaded.subscribe { view, url -> _textUrl.setText(url ?: ""); - if(!isFirstLoad) - return@subscribe; - isFirstLoad = false; - - if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) { - Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]"); - //TODO: Find most reliable way to wait for page js to finish - view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); - } - if(loginWarnings.size > 0 && url != null) { synchronized(loginWarnings) { val warning = loginWarnings.find { url.matches(it.getRegex()) }; @@ -106,6 +95,16 @@ class LoginActivity : AppCompatActivity() { } } + if(!isFirstLoad) + return@subscribe; + isFirstLoad = false; + + if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) { + Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]"); + //TODO: Find most reliable way to wait for page js to finish + view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); + } + /* var specifiedScale = false; var specifiedDesktop = false; From b1fce443e978011f30f941c9720f86e9480456f4 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 20 Aug 2025 08:39:12 -0400 Subject: [PATCH 26/46] fix pause and play buttons not working correctly in PiP Changelog: changed --- .../fragment/mainactivity/main/VideoDetailView.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index bc5b9a49..2b4abede 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -2517,6 +2517,7 @@ class VideoDetailView : ConstraintLayout { if (!StateCasting.instance.resumeVideo()) { _player.play(); } + onShouldEnterPictureInPictureChanged.emit() //TODO: This was needed because handleLowerVolume was done. //_player.setVolume(1.0f); @@ -2533,6 +2534,7 @@ class VideoDetailView : ConstraintLayout { if (!StateCasting.instance.pauseVideo()) { _player.pause(); } + onShouldEnterPictureInPictureChanged.emit() } private fun handleSeek(ms: Long) { Logger.i(TAG, "handleSeek(ms=$ms)") From ad97b5a406dc9b719dad1ee9eb1e2967bdc50cca Mon Sep 17 00:00:00 2001 From: zvonimir Date: Thu, 21 Aug 2025 17:59:08 +0200 Subject: [PATCH 27/46] fix: login prompt looping on search video --- .../fragment/mainactivity/main/VideoDetailView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 2b4abede..0818f9ed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1154,7 +1154,7 @@ class VideoDetailView : ConstraintLayout { //Recover cancelled loads if(video == null) { val t = (lastPositionMilliseconds / 1000.0f).roundToLong(); - if(_searchVideo != null) + if(_searchVideo != null && !wasLoginCall) setVideoOverview(_searchVideo!!, true, t); else if(_url != null && !wasLoginCall) setVideo(_url!!, t, _playWhenReady); From 40c4a51a2bced82fa85d97ceddbbb623563f6e3e Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 21 Aug 2025 20:41:18 +0200 Subject: [PATCH 28/46] Dialog input support, configurable relay server, radio views select/deselect all long press --- .../java/com/futo/platformplayer/Settings.kt | 33 ++++++++++++++ .../java/com/futo/platformplayer/UIDialogs.kt | 43 ++++++++++++++++--- .../futo/platformplayer/states/StateSync.kt | 5 ++- .../views/others/RadioGroupView.kt | 23 ++++++++++ .../platformplayer/views/others/RadioView.kt | 8 ++++ .../main/res/layout/dialog_multi_button.xml | 10 +++++ app/src/main/res/values/strings.xml | 2 + 7 files changed, 117 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 360a281c..8779a7fd 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePayment import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorageFileJson @@ -1092,6 +1093,38 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3) var localConnections: Boolean = true; + + + + var syncServerUrl: String? = null; + @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 6) + val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER; + + @FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7) + fun configureSyncServer() { + SettingsActivity.getActivity()?.let { context -> + UIDialogs.showDialog(context, R.drawable.device_sync, false, + "Enter the url to your relay server", + "Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.", + null, + syncServerUrl ?: "", + "YourRelayServerDomain.com", 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Reset", { + syncServerUrl = null; + instance.save(); + context.reloadSettings(); + UIDialogs.toast("Sync server changes require a restart"); + }, UIDialogs.ActionStyle.ACCENT), + UIDialogs.Action.withInput("Configure", { + syncServerUrl = it?.text + instance.save(); + context.reloadSettings(); + UIDialogs.toast("Sync server changes require a restart"); + }, UIDialogs.ActionStyle.PRIMARY), + ) + } + } } @FormField(R.string.info, FieldForm.GROUP, -1, 21) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 36755512..59917e75 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -113,8 +113,8 @@ class UIDialogs { currentDialog.code, currentDialog.defaultCloseAction, *currentDialog.actions.map { - return@map Action(it.text, { - it.action(); + return@map Action.withInput(it.text, { str -> + it.invokeAction(str); multiShowDialog(context, dialogDescriptor.drop(1), finally); }, it.style); }.toTypedArray()); @@ -203,7 +203,9 @@ class UIDialogs { fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions); } - fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { + fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog + = showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions); + fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog { val builder = AlertDialog.Builder(context); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); builder.setView(view); @@ -226,6 +228,16 @@ class UIDialogs { this.text = textDetails; } }; + var inputView = view.findViewById(R.id.dialog_text_input); + inputView.apply { + if (input == null && placeholder == null) this.visibility = View.GONE; + else { + this.text = input ?: ""; + this.hint = placeholder ?: ""; + this.visibility = View.VISIBLE; + this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START + } + }; view.findViewById(R.id.dialog_text_code).apply { if (code == null) this.visibility = View.GONE; else { @@ -250,7 +262,7 @@ class UIDialogs { buttonView.textSize = 14f; buttonView.typeface = resources.getFont(R.font.inter_regular); buttonView.text = act.text; - buttonView.setOnClickListener { act.action(); dialog.dismiss(); }; + buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); }; when(act.style) { ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary); ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent); @@ -275,7 +287,7 @@ class UIDialogs { }; dialog.setOnCancelListener { if(defaultCloseAction >= 0 && defaultCloseAction < actions.size) - actions[defaultCloseAction].action(); + actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString())); } dialog.setOnDismissListener { registerDialogClosed(dialog); @@ -535,17 +547,36 @@ class UIDialogs { } class Action { val text: String; - val action: ()->Unit; + val action: ((DialogResult?)->Unit); val style: ActionStyle; var center: Boolean; constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) { + this.text = text; + this.action = { action() }; + this.style = style; + this.center = center; + } + protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) { this.text = text; this.action = action; this.style = style; this.center = center; } + + fun invokeAction(input: DialogResult? = null) { + this.action(input); + } + + companion object { + fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action { + return Action(text, action, style, center); + } + } } + class DialogResult( + val text: String? + ); enum class ActionStyle { NONE, PRIMARY, diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 25cff055..8b620239 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -57,9 +57,12 @@ class StateSync { return } + var relayServerUrl = Settings.instance.synchronization.syncServer; + Logger.i(TAG, "Relay used: ${relayServerUrl}"); + syncService = SyncService( SERVICE_NAME, - RELAY_SERVER, + relayServerUrl, RELAY_PUBLIC_KEY, APP_ID, StoreBasedSyncDatabaseProvider(), diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt index 8c26d1e1..2004051e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt @@ -50,6 +50,29 @@ class RadioGroupView : FlexboxLayout { radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second)); radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px); + if(multiSelect) + radioView.onLongClick.subscribe { + val selected = !radioView.selected; + if (selected) { + selectedOptions.clear(); + for(v in radioViews) + v.setIsSelected(true); + selectedOptions.addAll(options.map { it.second }); + } else { + if(atLeastOne) { + for(v in radioViews) + v.setIsSelected(false); + selectedOptions.clear(); + selectedOptions.add(option.second); + } + else { + for(v in radioViews) + v.setIsSelected(false); + selectedOptions.clear(); + } + } + onSelectedChange.emit(selectedOptions); + } radioView.onClick.subscribe { val selected = !radioView.selected; if (selected) { diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt index 13a865a2..40f36467 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt @@ -20,6 +20,7 @@ class RadioView : LinearLayout { val selected get() = _selected; var onClick = Event0(); + var onLongClick = Event0(); var onSelectedChange = Event1(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { @@ -32,6 +33,13 @@ class RadioView : LinearLayout { setIsSelected(!_selected) } }; + _root.setOnLongClickListener { + onLongClick.emit(); + if (_handleClick) { + setIsSelected(!_selected) + } + return@setOnLongClickListener true; + } _root.setBackgroundResource(R.drawable.background_radio_unselected); _textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67)); diff --git a/app/src/main/res/layout/dialog_multi_button.xml b/app/src/main/res/layout/dialog_multi_button.xml index 9410b23a..2cc40874 100644 --- a/app/src/main/res/layout/dialog_multi_button.xml +++ b/app/src/main/res/layout/dialog_multi_button.xml @@ -63,6 +63,16 @@ android:layout_height="wrap_content" tools:ignore="HardcodedText" /> + + Test Background Worker Clear Payment + Configure Sync Server + Allows you to change the Sync Server to a self-hosted one. Clears cookies when you log out Clears in-app browser cookies Configure browsing behavior From 91a8996c110e086ad6944c5dced1c01dfd7f2114 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 21 Aug 2025 22:06:52 +0200 Subject: [PATCH 29/46] Shorts fix video size scaling for some aspect ratios, long press support for tags, home plugin filters now support long press to only select that one --- .../mainactivity/main/HomeFragment.kt | 8 +++++ .../futo/platformplayer/views/ToggleBar.kt | 34 +++++++++++++++--- .../views/others/ToggleTagView.kt | 35 +++++++++++++++---- app/src/main/res/layout/view_short_player.xml | 2 +- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 988d7a3f..0b393a08 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -279,6 +279,14 @@ class HomeFragment : MainFragment() { else { view.setToggle(!active); } + }, { view, views, enabled -> + val toDisable = views.filter { it != view && it.tag == "plugins" }; + if(!view.isActive) + view.handleClick(); + for(tag in toDisable) { + if(tag.isActive) + tag.handleClick(); + } }).withTag("plugins") }) else listOf()) diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index 4a545a26..647061a4 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.LinearLayout +import androidx.core.view.children import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView @@ -28,6 +29,8 @@ import kotlinx.coroutines.launch class ToggleBar : LinearLayout { private val _tagsContainer: LinearLayout; + private var allowLongPress: Boolean = false; + override fun onAttachedToWindow() { super.onAttachedToWindow(); } @@ -48,12 +51,31 @@ class ToggleBar : LinearLayout { for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { if(button.icon > 0) - this.setInfo(button.icon, button.name, button.isActive, button.isButton); + this.setInfo(button.icon, button.name, button.isActive, button.isButton, button.tag); else if(button.iconVariable != null) - this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); + this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag); else - this.setInfo(button.name, button.isActive, button.isButton); + this.setInfo(button.name, button.isActive, button.isButton, button.tag); this.onClick.subscribe({ view, enabled -> button.action(view, enabled); }); + if(allowLongPress) { + this.onLongClick.subscribe({ view, enabled -> + for (tagView in _tagsContainer.children.filter { it is ToggleTagView }) { + if (tagView != view && tagView is ToggleTagView && !tagView.isButton) { + if (enabled && !tagView.isActive) { + tagView.handleClick(); + } else if (!enabled && tagView.isActive) { + tagView.handleClick(); + } + } + } + }) + } + else if(button.actionLong != null) { + this.onLongClick.subscribe({ view, enabled -> + val tags = _tagsContainer.children.filter { it is ToggleTagView }.map { it as ToggleTagView }.toList(); + button.actionLong!!(view, tags, enabled); + }); + } }); } } @@ -63,16 +85,18 @@ class ToggleBar : LinearLayout { val icon: Int; val iconVariable: ImageVariable?; val action: (ToggleTagView, Boolean)->Unit; + val actionLong: ((ToggleTagView, List, Boolean) -> Unit)?; val isActive: Boolean; var isButton: Boolean = false private set; var tag: String? = null; - constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit, actionLong: ((ToggleTagView, List, Boolean)->Unit)? = null) { this.name = name; this.icon = 0; this.iconVariable = icon; this.action = action; + this.actionLong = actionLong; this.isActive = isActive; } constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { @@ -80,6 +104,7 @@ class ToggleBar : LinearLayout { this.icon = icon; this.iconVariable = null; this.action = action; + this.actionLong = null; this.isActive = isActive; } constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { @@ -87,6 +112,7 @@ class ToggleBar : LinearLayout { this.icon = 0; this.iconVariable = null; this.action = action; + this.actionLong = null; this.isActive = isActive; } diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 3ba65413..b7ce770c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -23,12 +23,16 @@ class ToggleTagView : LinearLayout { private var _text: String = ""; private var _image: ImageView; + var tag: String? = null + private set; + var isActive: Boolean = false private set; var isButton: Boolean = false private set; var onClick = Event2(); + var onLongClick = Event2(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); @@ -36,10 +40,25 @@ class ToggleTagView : LinearLayout { _textTag = findViewById(R.id.text_tag); _image = findViewById(R.id.image_tag); _root.setOnClickListener { - if(!isButton) - setToggle(!isActive); - onClick.emit(this, isActive); + handleClick(); } + _root.setOnLongClickListener { + if(onLongClick.hasListeners()) + onLongClick.emit(this, isActive); + else { + if(!isButton) { + setToggle(!isActive); + } + onClick.emit(this, isActive); + } + return@setOnLongClickListener true; + } + } + + fun handleClick() { + if(!isButton) + setToggle(!isActive); + onClick.emit(this, isActive); } fun setToggle(isActive: Boolean) { @@ -70,9 +89,10 @@ class ToggleTagView : LinearLayout { _image.visibility = View.VISIBLE; _textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; + tag = toggle.tag; } - fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) { _text = text; _textTag.text = text; setToggle(isActive); @@ -80,8 +100,9 @@ class ToggleTagView : LinearLayout { _image.visibility = View.VISIBLE; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; + this.tag = tag; } - fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { + fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) { _text = text; _textTag.text = text; setToggle(isActive); @@ -89,13 +110,15 @@ class ToggleTagView : LinearLayout { _image.visibility = View.VISIBLE; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; + this.tag = tag; } - fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { + fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) { _image.visibility = View.GONE; _text = text; _textTag.text = text; _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; setToggle(isActive); this.isButton = isButton; + this.tag = tag; } } \ No newline at end of file diff --git a/app/src/main/res/layout/view_short_player.xml b/app/src/main/res/layout/view_short_player.xml index ca3f7c70..a1815382 100644 --- a/app/src/main/res/layout/view_short_player.xml +++ b/app/src/main/res/layout/view_short_player.xml @@ -9,7 +9,7 @@ android:layout_above="@+id/short_player_progress_bar" android:background="@color/black" app:default_artwork="@drawable/placeholder_video_thumbnail" - app:resize_mode="fill" + app:resize_mode="zoom" app:show_buffering="when_playing" app:use_artwork="true" app:use_controller="false" /> From ac05edca77b194005a52eb39af8e7cf2a49d8b46 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 21 Aug 2025 22:14:30 +0200 Subject: [PATCH 30/46] Setting to disable short filling --- app/src/main/java/com/futo/platformplayer/Settings.kt | 6 ++++++ .../com/futo/platformplayer/views/video/FutoShortPlayer.kt | 7 +++++++ app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 16 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 8779a7fd..f66e493e 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -35,6 +35,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormFieldButton +import com.futo.platformplayer.views.fields.FormFieldWarning import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -609,6 +610,11 @@ class Settings : FragmentedStorageFileJson() { @AdvancedField @FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28) var shortsPregenerate: Boolean = false; + + @AdvancedField + @FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29) + @FormFieldWarning(R.string.shorts_fit_video_warning) + var shortsFitVideo: Boolean = false; } @FormField(R.string.comments, "group", R.string.comments_description, 6) diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt index 0b74781d..322a080a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -11,10 +11,12 @@ import androidx.annotation.OptIn import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView import androidx.media3.ui.TimeBar import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer @@ -66,6 +68,11 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : videoView = findViewById(R.id.short_player_view) progressBar = findViewById(R.id.short_player_progress_bar) + if(Settings.instance.playback.shortsFitVideo) + videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + else + videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F); if (!isInEditMode) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de730286..51d08ac4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -440,6 +440,9 @@ After you leave a video that you mostly watched, it will be removed from watch later. Pre-generate shorts sources Generates short sources (when applicable) one video ahead + Fit Shorts Video + Will scale the video to fit the view, instead of filling the view properly. + This setting will require you to reboot Grayjay. Seek duration Minimum Playback Speed Minimum Available Speed From 042966517313e2b5ee3a0274a8bf16f33a39937d Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 21 Aug 2025 22:18:00 +0200 Subject: [PATCH 31/46] Fix title for relay server --- app/src/main/java/com/futo/platformplayer/Settings.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index f66e493e..9f7a1b15 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -1103,7 +1103,7 @@ class Settings : FragmentedStorageFileJson() { var syncServerUrl: String? = null; - @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 6) + @FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6) val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER; @FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51d08ac4..ace799c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -478,6 +478,7 @@ Number of concurrent threads to multiply download speeds from throttled sources Payment Payment Status + Sync Relay Server Bypass Rotation Prevention Playlist Delete Confirmation Show confirmation dialog when deleting media from a playlist From 713d46c78164705d172718f65f6971a87ad9577f Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 21 Aug 2025 22:23:07 +0200 Subject: [PATCH 32/46] Refs --- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 91d3b683..f370a886 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 91d3b68324b27a996f08deadfe961e3935344ef8 +Subproject commit f370a88604433d5caf22d7e4910e28bd6d861e97 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 91d3b683..f370a886 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 91d3b68324b27a996f08deadfe961e3935344ef8 +Subproject commit f370a88604433d5caf22d7e4910e28bd6d861e97 From 2ca2a9db239193fef9270ebcf810461b6ddda0fa Mon Sep 17 00:00:00 2001 From: Kelvin Date: Sun, 24 Aug 2025 19:35:15 +0200 Subject: [PATCH 33/46] Workaround for global lifetime scope unavailable --- app/src/main/java/com/futo/platformplayer/Settings.kt | 1 + .../main/java/com/futo/platformplayer/states/StateApp.kt | 8 ++++++-- .../java/com/futo/platformplayer/views/fields/Field.kt | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 9f7a1b15..fdd06dad 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -1106,6 +1106,7 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6) val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER; + @AdvancedField @FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7) fun configureSyncServer() { SettingsActivity.getActivity()?.let { context -> diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index c7174c12..7883b4fd 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -135,8 +135,12 @@ class StateApp { return _scope; } val scope: CoroutineScope get() { - val thisScope = scopeOrNull - ?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available"); + val thisScope = scopeOrNull; + if(thisScope == null) { + //throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available"); + Logger.w(TAG, "Attempted to use a global lifetime scope while MainActivity is no longer available, USING GLOBAL SCOPE"); + return GlobalScope; + } return thisScope; } val scopeGetter: ()->CoroutineScope get() { diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt index b11ce641..bc2464dd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt @@ -4,7 +4,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event3 import java.lang.reflect.Field -@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdvancedField(); From 4d017ad3570f59f7e77973c909949415b83def98 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Sun, 24 Aug 2025 21:41:07 +0200 Subject: [PATCH 34/46] Refs --- app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 0fc4e8fb..f636e971 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 0fc4e8fbbf86eb1e53d17b40d2a0ac5f28a69905 +Subproject commit f636e9713d87e174b8d88284b42d88f072eeaf2e diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index f370a886..f1465628 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit f370a88604433d5caf22d7e4910e28bd6d861e97 +Subproject commit f1465628ecb67c9bceb96aed667355e6ddbb49d3 diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 0fc4e8fb..f636e971 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 0fc4e8fbbf86eb1e53d17b40d2a0ac5f28a69905 +Subproject commit f636e9713d87e174b8d88284b42d88f072eeaf2e diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index f370a886..f1465628 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit f370a88604433d5caf22d7e4910e28bd6d861e97 +Subproject commit f1465628ecb67c9bceb96aed667355e6ddbb49d3 From 14ae5f157202fd120458a64e2644009f46a47ab8 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 26 Aug 2025 21:32:13 +0200 Subject: [PATCH 35/46] Fixed translations to align. --- .../main/java/com/futo/platformplayer/Settings.kt | 2 ++ app/src/main/res/values-ar/strings.xml | 15 +++++++++++++++ app/src/main/res/values-de/strings.xml | 15 +++++++++++++++ app/src/main/res/values-es/strings.xml | 15 +++++++++++++++ app/src/main/res/values-fr/strings.xml | 15 +++++++++++++++ app/src/main/res/values-it/strings.xml | 13 ++----------- app/src/main/res/values-ja/strings.xml | 15 +++++++++++++++ app/src/main/res/values-ko/strings.xml | 15 +++++++++++++++ app/src/main/res/values-pt/strings.xml | 15 +++++++++++++++ app/src/main/res/values-ru/strings.xml | 15 +++++++++++++++ app/src/main/res/values-tr/strings.xml | 5 ++++- app/src/main/res/values-zh/strings.xml | 15 +++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 13 files changed, 145 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index fdd06dad..4a536855 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -203,6 +203,8 @@ class Settings : FragmentedStorageFileJson() { 8 -> "zh"; 9 -> "ru"; 10 -> "ar"; + 11 -> "it"; + 12 -> "tr"; else -> null } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 98898961..4724f6ce 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + النظام + الإنجليزية (EN) + الألمانية (DE) + الإسبانية (ES) + البرتغالية (PT) + الفرنسية (FR) + اليابانية (JA) + الكورية (KO) + الصينية (ZH) + الروسية (RU) + العربية (AR) + الإيطالية (IT) + التركية (TR) + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 51911585..234bb900 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + System + Englisch (EN) + Deutsch (DE) + Spanisch (ES) + Portugiesisch (PT) + Französisch (FR) + Japanisch (JA) + Koreanisch (KO) + Chinesisch (ZH) + Russisch (RU) + Arabisch (AR) + Italienisch (IT) + Türkisch (TR) + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 43461707..96b9449a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -714,4 +714,19 @@ Newest Oldest + + Sistema + Inglés (EN) + Alemán (DE) + Español (ES) + Portugués (PT) + Francés (FR) + Japonés (JA) + Coreano (KO) + Chino (ZH) + Ruso (RU) + Árabe (AR) + Italiano (IT) + Turco (TR) + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 438020da..557c485f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -712,4 +712,19 @@ Newest Oldest + + Système + Anglais (EN) + Allemand (DE) + Espagnol (ES) + Portugais (PT) + Français (FR) + Japonais (JA) + Coréen (KO) + Chinois (ZH) + Russe (RU) + Arabe (AR) + Italien (IT) + Turc (TR) + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9e6b7015..2defbce6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1028,7 +1028,6 @@ Sistema Inglese (EN) Tedesco (DE) - Italiano (IT) Spagnolo (ES) Portoghese (PT) Francese (FR) @@ -1037,6 +1036,8 @@ Cinese (ZH) Russo (RU) Arabo (AR) + Italiano (IT) + Turco (TR) Nessuno @@ -1109,16 +1110,6 @@ 4.0 5.0 - - 1.25 - 1.5 - 1.75 - 2.0 - 2.25 - 2.5 - 2.75 - 3.0 - 0.25 0.5 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1598bd60..e3452959 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + システム + 英語 (EN) + ドイツ語 (DE) + スペイン語 (ES) + ポルトガル語 (PT) + フランス語 (FR) + 日本語 (JA) + 韓国語 (KO) + 中国語 (ZH) + ロシア語 (RU) + アラビア語 (AR) + イタリア語 (IT) + トルコ語 (TR) + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b74f1c12..0312da2f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + 시스템 + 영어 (EN) + 독일어 (DE) + 스페인어 (ES) + 포르투갈어 (PT) + 프랑스어 (FR) + 일본어 (JA) + 한국어 (KO) + 중국어 (ZH) + 러시아어 (RU) + 아랍어 (AR) + 이탈리아어 (IT) + 터키어 (TR) + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ec593b0c..de46d305 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + Sistema + Inglês (EN) + Alemão (DE) + Espanhol (ES) + Português (PT) + Francês (FR) + Japonês (JA) + Coreano (KO) + Chinês (ZH) + Russo (RU) + Árabe (AR) + Italiano (IT) + Turco (TR) + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 79f775e8..82a479ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + Система + Английский (EN) + Немецкий (DE) + Испанский (ES) + Португальский (PT) + Французский (FR) + Японский (JA) + Корейский (KO) + Китайский (ZH) + Русский (RU) + Арабский (AR) + Итальянский (IT) + Турецкий (TR) + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index bb0d98a5..5a17d7b9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -409,7 +409,6 @@ İzlendikten sonra Daha Sonra İzle\'den kaldır Büyük bir çoğunluğunu izlediğiniz bir videoyu Daha Sonra İzle\'de bırakırsanız, Daha Sonra İzle\'den kaldırılacaktır. Arka planda sese değiştir - Mümkünse arka planda yalnızca ses akışına geçerek bant genişliği kullanımını optimize edin, bu takılmalara neden olabilir Gruplar Abonelik Gruplarını Göster Abonelik gruplarının filtrelemek için aboneliklerinizin üzerinde gösterilmesi gerekiyorsa @@ -969,6 +968,8 @@ İndirme Tarihi (En Yeni) Çıkış Tarihi (En Eski) Çıkış Tarihi (En Yeni) + Boyut (En Küçük) + Boyut (En Büyük) Önizle @@ -986,6 +987,8 @@ Çince (ZH) Rusça (RU) Arapça (AR) + İtalyanca (IT) + Türkçe (TR) Hiç diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 211cfe88..1e0e843f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -704,4 +704,19 @@ Newest Oldest + + 系统 + 英语 (EN) + 德语 (DE) + 西班牙语 (ES) + 葡萄牙语 (PT) + 法语 (FR) + 日语 (JA) + 韩语 (KO) + 中文 (ZH) + 俄语 (RU) + 阿拉伯语 (AR) + 意大利语 (IT) + 土耳其语 (TR) + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ace799c5..6bc87252 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1058,6 +1058,8 @@ Chinese (ZH) Russian (RU) Arabic (AR) + Italian (IT) + Turkish (TR) None From 8c1a18d8b4d34cb934cb2d10e2de4119b7b3dbdd Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 27 Aug 2025 09:58:54 +0200 Subject: [PATCH 36/46] Build fixes. --- app/src/main/res/values-it/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2defbce6..263067ba 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -15,7 +15,7 @@ Impostazioni avanzate Se le impostazioni avanzate sono visibili, verranno mostrate preferenze supplementari per affinare la tua esperienza d\'uso. Se la barra di avanzamento cronologica deve essere mostrata - Applica 'Nascosto dalla home' anche alla ricerca' + Applica \'Nascosto dalla home\' anche alla ricerca\' Nascondi video e creatori nascosti dalla scheda home anche nei risultati di ricerca Suggerimenti Altro @@ -427,7 +427,7 @@ La riproduzione automatica del video successivo sarà attiva per impostazione predefinita ogni volta che guarderai un video Consenti modalità verticale a schermo intero quando si guardano video orizzontali Elimina da Guarda più tardi dopo visione - Dopo aver chiuso un video che hai guardato in larga parte, questo verrà rimosso da 'Guarda più tardi'. + Dopo aver chiuso un video che hai guardato in larga parte, questo verrà rimosso da \'Guarda più tardi\'. Durata ricerca Velocità di riproduzione minima Velocità minima disponibile @@ -758,7 +758,7 @@ Sottotitoli Video non disponibile Questo video non è disponibile. - C'era un video non disponibile nella tua coda: [{videoName}] di [{authorName}]. + C\'era un video non disponibile nella tua coda: [{videoName}] di [{authorName}]. Indietro Pausa Riproduci @@ -833,7 +833,7 @@ Aggiungi a nuova playlist Gestione URL Consentire a Grayjay di gestire URL specifici? - Quando fai clic su "Sì", si apriranno le impostazioni dell\'app Grayjay.\n\nDa lì, accedi a:\n1. Sezione 'Apri per impostazione predefinita' o 'Imposta come predefinito'.\nPotresti trovare questa opzione direttamente oppure nella categoria 'Impostazioni Avanzate', a seconda del tuo dispositivo.\n\n2. Seleziona 'Apri link supportati' per Grayjay.\n\n(alcuni dispositivi hanno questa opzione elencata nella finestra 'App predefinite' delle impostazioni di sistema, seguita dalla possibilità di selezionare Grayjay per le categorie pertinenti) + Quando fai clic su "Sì", si apriranno le impostazioni dell\'app Grayjay.\n\nDa lì, accedi a:\n1. Sezione \'Apri per impostazione predefinita\' o \'Imposta come predefinito\'.\nPotresti trovare questa opzione direttamente oppure nella categoria \'Impostazioni Avanzate\', a seconda del tuo dispositivo.\n\n2. Seleziona \'Apri link supportati\' per Grayjay.\n\n(alcuni dispositivi hanno questa opzione elencata nella finestra \'App predefinite\' delle impostazioni di sistema, seguita dalla possibilità di selezionare Grayjay per le categorie pertinenti) Apertura impostazioni non riuscita La versione del Play Store non supporta la gestione URL predefinita. Questi sono tutti i {commentCount} commenti che hai postato con Grayjay. @@ -852,7 +852,7 @@ Riproduci/Pausa Posizione Tutorial - Vuoi guardare i tutorial? Puoi accedervi in qualsiasi momento cliccando sul pulsante 'Altro'. + Vuoi guardare i tutorial? Puoi accedervi in qualsiasi momento cliccando sul pulsante \'Altro\'. Aggiungi creatori Seleziona Zoom @@ -861,7 +861,7 @@ Disattiva ottimizzazione batteria Clicca per accedere alle impostazioni di ottimizzazione della batteria. Disattivare l\'ottimizzazione della batteria impedirà al sistema operativo di terminare le sessioni multimediali. Contribuisci con l\'elenco delle sottoscrizioni personali - \nTi piacerebbe contribuire a FUTO con la tua attuale lista di sottoscrizioni ai creatori?\n\nI dati saranno gestiti in conformità con la politica sulla privacy di Grayjay. Ovvero, la lista sarà anonimizzata e archiviata senza alcun riferimento a chiunque appartenga l\'elenco dei creatori.\n\nL\'intenzione è che Grayjay e FUTO utilizzino questi dati per costruire un sistema di suggerimenti dei creatori multipiattaforma per rendere più facile trovarne di nuovi che potrebbero piacerti direttamente all\'interno di Grayjay. + \nTi piacerebbe contribuire a FUTO con la tua attuale lista di sottanother sanityoscrizioni ai creatori?\n\nI dati saranno gestiti in conformità con la politica sulla privacy di Grayjay. Ovvero, la lista sarà anonimizzata e archiviata senza alcun riferimento a chiunque appartenga l\'elenco dei creatori.\n\nL\'intenzione è che Grayjay e FUTO utilizzino questi dati per costruire un sistema di suggerimenti dei creatori multipiattaforma per rendere più facile trovarne di nuovi che potrebbero piacerti direttamente all\'interno di Grayjay. Pulsante trasmetti Pulsante incognito Miniatura creatore From a9bb9009943eb91b2054e2de707e0d9ae540dab3 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 8 Sep 2025 19:00:41 +0200 Subject: [PATCH 37/46] Change when plugins are disabled on reload and listing reloads --- .../platformplayer/states/StatePlatform.kt | 37 ++++++++++++++----- .../platformplayer/states/StatePlugins.kt | 29 ++++++++------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 972ce336..8f5fc3ba 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -177,16 +177,11 @@ class StatePlatform { } withContext(Dispatchers.IO) { + var toDisables = mutableListOf(); var enabled: Array; synchronized(_clientsLock) { for(e in _enabledClients) { - try { - e.disable(); - onSourceDisabled.emit(e); - } - catch(ex: Throwable) { - UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable")); - } + toDisables.add(e); } _enabledClients.clear(); @@ -236,6 +231,18 @@ class StatePlatform { } } selectClients(*enabled); + + for(toDisable in toDisables) { + launch(Dispatchers.IO) { + try { + toDisable.disable(); + onSourceDisabled.emit(toDisable); + } + catch(ex: Throwable) { + Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER UpdateAvailableClients", ex); + } + } + } }; } @@ -348,11 +355,11 @@ class StatePlatform { StateApp.instance.handleCaptchaException(c, ex); } + var toDisable: IPlatformClient? = null; synchronized(_clientsLock) { if (_enabledClients.contains(client)) { _enabledClients.remove(client); - client.disable(); - onSourceDisabled.emit(client); + toDisable = client; newClient.initialize(); _enabledClients.add(newClient); } @@ -360,6 +367,18 @@ class StatePlatform { _availableClients.removeIf { it.id == id }; _availableClients.add(newClient); } + if(toDisable != null) { + launch(Dispatchers.IO) { + try { + toDisable?.disable(); + onSourceDisabled.emit(client); + } + catch (ex: Throwable) { + Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER RELOAD", ex); + } + } + } + afterReload?.invoke(); return@withContext newClient; }; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index e80a2df3..65674610 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -402,18 +402,25 @@ class StatePlugins { } val icon = config.absoluteIconUrl?.let { absIconUrl -> - withContext(Dispatchers.Main) { - it.setText("Saving plugin..."); - it.setProgress(0.75); - } val iconResp = client.get(absIconUrl); if(iconResp.isOk) return@let iconResp.body?.byteStream()?.use { it.readBytes() }; return@let null; } + + withContext(Dispatchers.Main) { + it.setText("Saving plugin..."); + it.setProgress(0.75); + } + val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall); if(installEx != null) throw installEx; + + withContext(Dispatchers.Main) { + it.setText("Reloading available plugins..."); + it.setProgress(0.9); + } StatePlatform.instance.updateAvailableClients(context); withContext(Dispatchers.Main) { @@ -522,9 +529,7 @@ class StatePlugins { if(id == StateDeveloper.DEV_ID) throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed"); - synchronized(_plugins) { - return _plugins.findItem { it.config.id == id }; - } + return _plugins.findItem { it.config.id == id }; } fun getPlugins(): List { return _plugins.getItems(); @@ -533,12 +538,10 @@ class StatePlugins { fun deletePlugin(id: String) { synchronized(_pluginScripts) { - synchronized(_plugins) { - _pluginScripts.deleteFile(id); - val plugins = _plugins.findItems { it.config.id == id }; - for(plugin in plugins) - _plugins.delete(plugin); - } + _pluginScripts.deleteFile(id); + val plugins = _plugins.findItems { it.config.id == id }; + for(plugin in plugins) + _plugins.delete(plugin); } } fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List = listOf()) : Throwable? { From 4fc33411fdb53bda3a453009ac5957c818e9b0d1 Mon Sep 17 00:00:00 2001 From: Marcus Hanestad Date: Wed, 10 Sep 2025 15:26:16 +0000 Subject: [PATCH 38/46] Experimental casting backend --- app/build.gradle | 6 + .../java/com/futo/platformplayer/Settings.kt | 5 + .../platformplayer/activities/MainActivity.kt | 2 +- .../casting/AirPlayCastingDevice.kt | 2 +- .../platformplayer/casting/CastingDevice.kt | 189 ++--- .../casting/CastingDeviceExp.kt | 271 ++++++ .../casting/CastingDeviceLegacy.kt | 242 ++++++ .../casting/ChomecastCastingDevice.kt | 2 +- .../casting/FCastCastingDevice.kt | 5 +- .../platformplayer/casting/StateCasting.kt | 797 +++++++----------- .../platformplayer/casting/StateCastingExp.kt | 174 ++++ .../casting/StateCastingLegacy.kt | 397 +++++++++ .../dialogs/CastingAddDialog.kt | 16 +- .../dialogs/ConnectCastingDialog.kt | 9 +- .../dialogs/ConnectedCastingDialog.kt | 66 +- .../mainactivity/main/VideoDetailView.kt | 13 +- .../models/CastingDeviceInfo.kt | 19 +- .../views/adapters/DeviceViewHolder.kt | 64 +- .../views/casting/CastButton.kt | 5 - .../platformplayer/views/casting/CastView.kt | 54 +- app/src/main/res/drawable/ic_exp_fc.xml | 14 + app/src/main/res/values/strings.xml | 6 + 22 files changed, 1598 insertions(+), 760 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/CastingDeviceLegacy.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/StateCastingExp.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/StateCastingLegacy.kt create mode 100644 app/src/main/res/drawable/ic_exp_fc.xml diff --git a/app/build.gradle b/app/build.gradle index 65f600c6..5b375434 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -231,4 +231,10 @@ dependencies { testImplementation "org.mockito:mockito-core:5.4.0" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + //Rust casting SDK + implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') { + // Polycentricandroid includes this + exclude group: 'net.java.dev.jna' + } } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 4a536855..d67a531a 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -719,6 +719,11 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var allowLinkLocalIpv4: Boolean = false; + @AdvancedField + @FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6) + @Serializable(with = FlexibleBooleanSerializer::class) + var experimentalCasting: Boolean = false + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 0d5bf8d9..5e4e1e42 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1046,7 +1046,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.i(TAG, "handleFCast"); try { - StateCasting.instance.handleUrl(this, url) + StateCasting.instance.handleUrl(url) return true; } catch (e: Throwable) { Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 0cc1bebc..b8ea2df6 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.launch import java.net.InetAddress import java.util.UUID -class AirPlayCastingDevice : CastingDevice { +class AirPlayCastingDevice : CastingDeviceLegacy { //See for more info: https://nto.github.io/AirPlay override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY; diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 69f74747..cb6c9ab0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -2,147 +2,78 @@ package com.futo.platformplayer.casting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.CastingDeviceInfo -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder +import org.fcast.sender_sdk.Metadata import java.net.InetAddress -enum class CastConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED -} - -@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class) -enum class CastProtocolType { - CHROMECAST, - AIRPLAY, - FCAST; - - object CastProtocolTypeSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: CastProtocolType) { - encoder.encodeString(value.name) - } - - override fun deserialize(decoder: Decoder): CastProtocolType { - val name = decoder.decodeString() - return when (name) { - "FASTCAST" -> FCAST // Handle the renamed case - else -> CastProtocolType.valueOf(name) - } - } - } -} - abstract class CastingDevice { - abstract val protocol: CastProtocolType; - abstract val isReady: Boolean; - abstract var usedRemoteAddress: InetAddress?; - abstract var localAddress: InetAddress?; - abstract val canSetVolume: Boolean; - abstract val canSetSpeed: Boolean; + abstract val isReady: Boolean + abstract val usedRemoteAddress: InetAddress? + abstract val localAddress: InetAddress? + abstract val name: String? + abstract val onConnectionStateChanged: Event1 + abstract val onPlayChanged: Event1 + abstract val onTimeChanged: Event1 + abstract val onDurationChanged: Event1 + abstract val onVolumeChanged: Event1 + abstract val onSpeedChanged: Event1 + abstract var connectionState: CastConnectionState + abstract val protocolType: CastProtocolType + abstract var isPlaying: Boolean + abstract val expectedCurrentTime: Double + abstract var speed: Double + abstract var time: Double + abstract var duration: Double + abstract var volume: Double + abstract fun canSetVolume(): Boolean + abstract fun canSetSpeed(): Boolean - var name: String? = null; - var isPlaying: Boolean = false - set(value) { - val changed = value != field; - field = value; - if (changed) { - onPlayChanged.emit(value); - } - }; + @Throws + abstract fun resumePlayback() - private var lastTimeChangeTime_ms: Long = 0 - var time: Double = 0.0 - private set + @Throws + abstract fun pausePlayback() - protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastTimeChangeTime_ms && value != time) { - time = value - lastTimeChangeTime_ms = changeTime_ms - onTimeChanged.emit(value) - } - } + @Throws + abstract fun stopPlayback() - private var lastDurationChangeTime_ms: Long = 0 - var duration: Double = 0.0 - private set + @Throws + abstract fun seekTo(timeSeconds: Double) - protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { - duration = value - lastDurationChangeTime_ms = changeTime_ms - onDurationChanged.emit(value) - } - } + @Throws + abstract fun changeVolume(timeSeconds: Double) - private var lastVolumeChangeTime_ms: Long = 0 - var volume: Double = 1.0 - private set + @Throws + abstract fun changeSpeed(speed: Double) - protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { - volume = value - lastVolumeChangeTime_ms = changeTime_ms - onVolumeChanged.emit(value) - } - } + @Throws + abstract fun connect() - private var lastSpeedChangeTime_ms: Long = 0 - var speed: Double = 1.0 - private set + @Throws + abstract fun disconnect() + abstract fun getDeviceInfo(): CastingDeviceInfo + abstract fun getAddresses(): List - protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { - if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { - speed = value - lastSpeedChangeTime_ms = changeTime_ms - onSpeedChanged.emit(value) - } - } + @Throws + abstract fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - val expectedCurrentTime: Double - get() { - val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; - return time + diff; - }; - var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED - set(value) { - val changed = value != field; - field = value; + @Throws + abstract fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) - if (changed) { - onConnectionStateChanged.emit(value); - } - }; + abstract fun ensureThreadStarted() +} - var onConnectionStateChanged = Event1(); - var onPlayChanged = Event1(); - var onTimeChanged = Event1(); - var onDurationChanged = Event1(); - var onVolumeChanged = Event1(); - var onSpeedChanged = Event1(); - - abstract fun stopCasting(); - - abstract fun seekVideo(timeSeconds: Double); - abstract fun stopVideo(); - abstract fun pauseVideo(); - abstract fun resumeVideo(); - abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?); - abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?); - open fun changeVolume(volume: Double) { throw NotImplementedError() } - open fun changeSpeed(speed: Double) { throw NotImplementedError() } - - abstract fun start(); - abstract fun stop(); - - abstract fun getDeviceInfo(): CastingDeviceInfo; - - abstract fun getAddresses(): List; -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt new file mode 100644 index 00000000..1560eff1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt @@ -0,0 +1,271 @@ +package com.futo.platformplayer.casting + +import android.os.Build +import com.futo.platformplayer.BuildConfig +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import org.fcast.sender_sdk.ApplicationInfo +import org.fcast.sender_sdk.GenericKeyEvent +import org.fcast.sender_sdk.GenericMediaEvent +import org.fcast.sender_sdk.PlaybackState +import org.fcast.sender_sdk.Source +import java.net.InetAddress +import org.fcast.sender_sdk.CastingDevice as RsCastingDevice; +import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; +import org.fcast.sender_sdk.DeviceConnectionState +import org.fcast.sender_sdk.DeviceFeature +import org.fcast.sender_sdk.IpAddr +import org.fcast.sender_sdk.LoadRequest +import org.fcast.sender_sdk.Metadata +import org.fcast.sender_sdk.ProtocolType +import org.fcast.sender_sdk.urlFormatIpAddr +import java.net.Inet4Address +import java.net.Inet6Address + +private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) { + is IpAddr.V4 -> Inet4Address.getByAddress( + byteArrayOf( + addr.o1.toByte(), + addr.o2.toByte(), + addr.o3.toByte(), + addr.o4.toByte() + ) + ) + + is IpAddr.V6 -> Inet6Address.getByAddress( + byteArrayOf( + addr.o1.toByte(), + addr.o2.toByte(), + addr.o3.toByte(), + addr.o4.toByte(), + addr.o5.toByte(), + addr.o6.toByte(), + addr.o7.toByte(), + addr.o8.toByte(), + addr.o9.toByte(), + addr.o10.toByte(), + addr.o11.toByte(), + addr.o12.toByte(), + addr.o13.toByte(), + addr.o14.toByte(), + addr.o15.toByte(), + addr.o16.toByte() + ) + ) +} + +class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { + class EventHandler : RsDeviceEventHandler { + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1() + var onTimeChanged = Event1() + var onDurationChanged = Event1() + var onVolumeChanged = Event1() + var onSpeedChanged = Event1() + + override fun connectionStateChanged(state: DeviceConnectionState) { + onConnectionStateChanged.emit(state) + } + + override fun volumeChanged(volume: Double) { + onVolumeChanged.emit(volume) + } + + override fun timeChanged(time: Double) { + onTimeChanged.emit(time) + } + + override fun playbackStateChanged(state: PlaybackState) { + onPlayChanged.emit(state == PlaybackState.PLAYING) + } + + override fun durationChanged(duration: Double) { + onDurationChanged.emit(duration) + } + + override fun speedChanged(speed: Double) { + onSpeedChanged.emit(speed) + } + + override fun sourceChanged(source: Source) { + // TODO + } + + override fun keyEvent(event: GenericKeyEvent) { + // Unreachable + } + + override fun mediaEvent(event: GenericMediaEvent) { + // Unreachable + } + + override fun playbackError(message: String) { + Logger.e(TAG, "Playback error: $message") + } + } + + val eventHandler = EventHandler() + override val isReady: Boolean + get() = device.isReady() + override val name: String + get() = device.name() + override var usedRemoteAddress: InetAddress? = null + override var localAddress: InetAddress? = null + override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME) + override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED) + + override val onConnectionStateChanged = + Event1() + override val onPlayChanged: Event1 + get() = eventHandler.onPlayChanged + override val onTimeChanged: Event1 + get() = eventHandler.onTimeChanged + override val onDurationChanged: Event1 + get() = eventHandler.onDurationChanged + override val onVolumeChanged: Event1 + get() = eventHandler.onVolumeChanged + override val onSpeedChanged: Event1 + get() = eventHandler.onSpeedChanged + + override fun resumePlayback() = device.resumePlayback() + override fun pausePlayback() = device.pausePlayback() + override fun stopPlayback() = device.stopPlayback() + override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds) + override fun changeVolume(newVolume: Double) { + device.changeVolume(newVolume) + volume = newVolume + } + override fun changeSpeed(speed: Double) = device.changeSpeed(speed) + override fun connect() = device.connect( + ApplicationInfo( + "Grayjay Android", + "${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}", + "${Build.MANUFACTURER} ${Build.MODEL}" + ), + eventHandler, + 1000.toULong() + ) + + override fun disconnect() = device.disconnect() + + override fun getDeviceInfo(): CastingDeviceInfo { + val info = device.getDeviceInfo() + return CastingDeviceInfo( + info.name, + when (info.protocol) { + ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST + ProtocolType.F_CAST -> CastProtocolType.FCAST + }, + addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(), + port = info.port.toInt(), + ) + } + + override fun getAddresses(): List = device.getAddresses().map { + ipAddrToInetAddress(it) + } + + override fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = device.load( + LoadRequest.Video( + contentType = contentType, + url = contentId, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata + ) + ) + + override fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = device.load( + LoadRequest.Content( + contentType = contentType, + content = content, + resumePosition = resumePosition, + speed = speed, + volume = volume, + metadata = metadata, + ) + ) + + override var connectionState = CastConnectionState.DISCONNECTED + override val protocolType: CastProtocolType + get() = when (device.castingProtocol()) { + ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST + ProtocolType.F_CAST -> CastProtocolType.FCAST + } + override var volume: Double = 1.0 + override var duration: Double = 0.0 + private var lastTimeChangeTime_ms: Long = 0 + override var time: Double = 0.0 + override var speed: Double = 0.0 + override var isPlaying: Boolean = false + + override val expectedCurrentTime: Double + get() { + val diff = + if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + return time + diff + } + + init { + eventHandler.onConnectionStateChanged.subscribe { newState -> + when (newState) { + is DeviceConnectionState.Connected -> { + usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr) + localAddress = ipAddrToInetAddress(newState.localAddr) + connectionState = CastConnectionState.CONNECTED + onConnectionStateChanged.emit(CastConnectionState.CONNECTED) + } + + DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> { + connectionState = CastConnectionState.CONNECTING + onConnectionStateChanged.emit(CastConnectionState.CONNECTING) + } + + DeviceConnectionState.Disconnected -> { + connectionState = CastConnectionState.CONNECTING + onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) + } + } + + if (newState == DeviceConnectionState.Disconnected) { + try { + Logger.i(TAG, "Stopping device") + device.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop device: $e") + } + } + } + eventHandler.onPlayChanged.subscribe { isPlaying = it } + eventHandler.onTimeChanged.subscribe { + lastTimeChangeTime_ms = System.currentTimeMillis() + time = it + } + eventHandler.onDurationChanged.subscribe { duration = it } + eventHandler.onVolumeChanged.subscribe { volume = it } + eventHandler.onSpeedChanged.subscribe { speed = it } + } + + override fun ensureThreadStarted() {} + + companion object { + private val TAG = "CastingDeviceExp" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceLegacy.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceLegacy.kt new file mode 100644 index 00000000..d9ed6956 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceLegacy.kt @@ -0,0 +1,242 @@ +package com.futo.platformplayer.casting + +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.fcast.sender_sdk.Metadata +import java.net.InetAddress + +enum class CastConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED +} + +@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class) +enum class CastProtocolType { + CHROMECAST, + AIRPLAY, + FCAST; + + object CastProtocolTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CastProtocolType) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): CastProtocolType { + val name = decoder.decodeString() + return when (name) { + "FASTCAST" -> FCAST // Handle the renamed case + else -> CastProtocolType.valueOf(name) + } + } + } +} + +abstract class CastingDeviceLegacy { + abstract val protocol: CastProtocolType; + abstract val isReady: Boolean; + abstract var usedRemoteAddress: InetAddress?; + abstract var localAddress: InetAddress?; + abstract val canSetVolume: Boolean; + abstract val canSetSpeed: Boolean; + + var name: String? = null; + var isPlaying: Boolean = false + set(value) { + val changed = value != field; + field = value; + if (changed) { + onPlayChanged.emit(value); + } + }; + + private var lastTimeChangeTime_ms: Long = 0 + var time: Double = 0.0 + private set + + protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastTimeChangeTime_ms && value != time) { + time = value + lastTimeChangeTime_ms = changeTime_ms + onTimeChanged.emit(value) + } + } + + private var lastDurationChangeTime_ms: Long = 0 + var duration: Double = 0.0 + private set + + protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { + duration = value + lastDurationChangeTime_ms = changeTime_ms + onDurationChanged.emit(value) + } + } + + private var lastVolumeChangeTime_ms: Long = 0 + var volume: Double = 1.0 + private set + + protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { + volume = value + lastVolumeChangeTime_ms = changeTime_ms + onVolumeChanged.emit(value) + } + } + + private var lastSpeedChangeTime_ms: Long = 0 + var speed: Double = 1.0 + private set + + protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { + speed = value + lastSpeedChangeTime_ms = changeTime_ms + onSpeedChanged.emit(value) + } + } + + val expectedCurrentTime: Double + get() { + val diff = + if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + return time + diff; + }; + var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED + set(value) { + val changed = value != field; + field = value; + + if (changed) { + onConnectionStateChanged.emit(value); + } + }; + + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1(); + var onTimeChanged = Event1(); + var onDurationChanged = Event1(); + var onVolumeChanged = Event1(); + var onSpeedChanged = Event1(); + + abstract fun stopCasting(); + + abstract fun seekVideo(timeSeconds: Double); + abstract fun stopVideo(); + abstract fun pauseVideo(); + abstract fun resumeVideo(); + abstract fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double? + ); + + abstract fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double? + ); + + open fun changeVolume(volume: Double) { + throw NotImplementedError() + } + + open fun changeSpeed(speed: Double) { + throw NotImplementedError() + } + + abstract fun start(); + abstract fun stop(); + + abstract fun getDeviceInfo(): CastingDeviceInfo; + + abstract fun getAddresses(): List; +} + +class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() { + override val isReady: Boolean get() = inner.isReady + override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress + override val localAddress: InetAddress? get() = inner.localAddress + override val name: String? get() = inner.name + override val onConnectionStateChanged: Event1 get() = inner.onConnectionStateChanged + override val onPlayChanged: Event1 get() = inner.onPlayChanged + override val onTimeChanged: Event1 get() = inner.onTimeChanged + override val onDurationChanged: Event1 get() = inner.onDurationChanged + override val onVolumeChanged: Event1 get() = inner.onVolumeChanged + override val onSpeedChanged: Event1 get() = inner.onSpeedChanged + override var connectionState: CastConnectionState + get() = inner.connectionState + set(_) = Unit + override val protocolType: CastProtocolType get() = inner.protocol + override var isPlaying: Boolean + get() = inner.isPlaying + set(_) = Unit + override val expectedCurrentTime: Double + get() = inner.expectedCurrentTime + override var speed: Double + get() = inner.speed + set(_) = Unit + override var time: Double + get() = inner.time + set(_) = Unit + override var duration: Double + get() = inner.duration + set(_) = Unit + override var volume: Double + get() = inner.volume + set(_) = Unit + + override fun canSetVolume(): Boolean = inner.canSetVolume + override fun canSetSpeed(): Boolean = inner.canSetSpeed + override fun resumePlayback() = inner.resumeVideo() + override fun pausePlayback() = inner.pauseVideo() + override fun stopPlayback() = inner.stopVideo() + override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds) + override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds) + override fun changeSpeed(speed: Double) = inner.changeSpeed(speed) + override fun connect() = inner.start() + override fun disconnect() = inner.stop() + override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo() + override fun getAddresses(): List = inner.getAddresses() + override fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) + + override fun loadContent( + contentType: String, + content: String, + resumePosition: Double, + duration: Double, + speed: Double?, + metadata: Metadata? + ) = inner.loadContent(contentType, content, resumePosition, duration, speed) + + override fun ensureThreadStarted() = when (inner) { + is FCastCastingDevice -> inner.ensureThreadStarted() + is ChromecastCastingDevice -> inner.ensureThreadsStarted() + else -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index f6582055..ed10832c 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -class ChromecastCastingDevice : CastingDevice { +class ChromecastCastingDevice : CastingDeviceLegacy { //See for more info: https://developers.google.com/cast/docs/media/messages override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST; diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index be62f726..71d1890b 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Base64 import android.util.Log -import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage @@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -34,7 +32,6 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.math.BigInteger -import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket @@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) { } } -class FCastCastingDevice : CastingDevice { +class FCastCastingDevice : CastingDeviceLegacy { //See for more info: TODO override val protocol: CastProtocolType get() = CastProtocolType.FCAST; diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index f7802e86..4c3e3323 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -3,15 +3,8 @@ package com.futo.platformplayer.casting import android.app.AlertDialog import android.content.ContentResolver import android.content.Context -import android.net.Uri -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.Build import android.os.Looper -import android.util.Base64 import android.util.Log -import java.net.NetworkInterface -import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -41,39 +34,38 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.builders.DashBuilder +import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.findPreferredAddress import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toUrlAddress +import com.futo.platformplayer.views.casting.CastView +import com.futo.platformplayer.views.casting.CastView.Companion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json +import org.fcast.sender_sdk.Metadata import java.net.Inet6Address -import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder -import java.util.Collections import java.util.UUID import java.util.concurrent.atomic.AtomicInteger -class StateCasting { - private val _scopeIO = CoroutineScope(Dispatchers.IO); - private val _scopeMain = CoroutineScope(Dispatchers.Main); +abstract class StateCasting { + val _scopeIO = CoroutineScope(Dispatchers.IO); + val _scopeMain = CoroutineScope(Dispatchers.Main); private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); - private val _castServer = ManagedHttpServer(); - private var _started = false; + val _castServer = ManagedHttpServer(); + var _started = false; var devices: HashMap = hashMapOf(); val onDeviceAdded = Event1(); @@ -89,212 +81,46 @@ class StateCasting { private var _audioExecutor: JSRequestExecutor? = null private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; - private var _nsdManager: NsdManager? = null val isCasting: Boolean get() = activeDevice != null; private val _castId = AtomicInteger(0) - private val _discoveryListeners = mapOf( - "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), - "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), - "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), - "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) - ) + abstract fun handleUrl(url: String) + abstract fun onStop() + abstract fun start(context: Context) + abstract fun stop() - fun handleUrl(context: Context, url: String) { - val uri = Uri.parse(url) - if (uri.scheme != "fcast") { - throw Exception("Expected scheme to be FCast") - } - - val type = uri.host - if (type != "r") { - throw Exception("Expected type r") - } - - val connectionInfo = uri.pathSegments[0] - val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8) - val networkConfig = Json.decodeFromString(json) - val tcpService = networkConfig.services.first { v -> v.type == 0 } - - val foundInfo = addRememberedDevice(CastingDeviceInfo( - name = networkConfig.name, - type = CastProtocolType.FCAST, - addresses = networkConfig.addresses.toTypedArray(), - port = tcpService.port - )) - - connectDevice(deviceFromCastingDeviceInfo(foundInfo)) - } - - fun onStop() { - val ad = activeDevice ?: return; - _resumeCastingDevice = ad.getDeviceInfo() - Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") - Logger.i(TAG, "Stopping active device because of onStop."); - ad.stop(); - } + @Throws + abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice + abstract fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, setTime: (Long) -> Unit + ): Job? fun onResume() { val ad = activeDevice if (ad != null) { - if (ad is FCastCastingDevice) { - ad.ensureThreadStarted() - } else if (ad is ChromecastCastingDevice) { - ad.ensureThreadsStarted() - } + ad.ensureThreadStarted() } else { val resumeCastingDevice = _resumeCastingDevice if (resumeCastingDevice != null) { - connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice)) + val dev = deviceFromInfo(resumeCastingDevice) ?: return + connectDevice(dev) _resumeCastingDevice = null Log.i(TAG, "_resumeCastingDevice set to null onResume") } } } - @Synchronized - fun start(context: Context) { - if (_started) + fun cancel() { + _castId.incrementAndGet() + } + + fun invokeInMainScopeIfRequired(action: () -> Unit) { + if (Looper.getMainLooper().thread != Thread.currentThread()) { + _scopeMain.launch { action() } return; - _started = true; - - Log.i(TAG, "_resumeCastingDevice set null start") - _resumeCastingDevice = null; - - Logger.i(TAG, "CastingService starting..."); - - _castServer.start(); - enableDeveloper(true); - - Logger.i(TAG, "CastingService started."); - - _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager - startDiscovering() - } - - @Synchronized - private fun startDiscovering() { - _nsdManager?.apply { - _discoveryListeners.forEach { - discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) - } } - } - @Synchronized - private fun stopDiscovering() { - _nsdManager?.apply { - _discoveryListeners.forEach { - try { - stopServiceDiscovery(it.value) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - } - } - - @Synchronized - fun stop() { - if (!_started) - return; - - _started = false; - - Logger.i(TAG, "CastingService stopping.") - - stopDiscovering() - _scopeIO.cancel(); - _scopeMain.cancel(); - - Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") - val d = activeDevice; - activeDevice = null; - d?.stop(); - - _castServer.stop(); - _castServer.removeAllHandlers(); - - Logger.i(TAG, "CastingService stopped.") - - _nsdManager = null - } - - private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { - return object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(regType: String) { - Log.d(TAG, "Service discovery started for $regType") - } - - override fun onDiscoveryStopped(serviceType: String) { - Log.i(TAG, "Discovery stopped: $serviceType") - } - - override fun onServiceLost(service: NsdServiceInfo) { - Log.e(TAG, "service lost: $service") - // TODO: Handle service lost, e.g., remove device - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onServiceFound(service: NsdServiceInfo) { - Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") - val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.hostAddresses.toTypedArray() - } else { - arrayOf(service.host) - } - addOrUpdate(service.serviceName, addresses, service.port) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUpdated: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port) - } - - override fun onServiceLost() { - Log.v(TAG, "onServiceLost: $service") - // TODO: Handle service lost - } - - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") - } - - override fun onServiceInfoCallbackUnregistered() { - Log.v(TAG, "onServiceInfoCallbackUnregistered") - } - }) - } else { - _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "Resolve failed: $errorCode") - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "Resolve Succeeded: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port) - } - }) - } - } - } + action(); } private val _castingDialogLock = Any(); @@ -302,8 +128,9 @@ class StateCasting { @Synchronized fun connectDevice(device: CastingDevice) { - if (activeDevice == device) - return; + if (activeDevice == device) { + return + } val ad = activeDevice; if (ad != null) { @@ -313,11 +140,11 @@ class StateCasting { device.onTimeChanged.clear(); device.onVolumeChanged.clear(); device.onDurationChanged.clear(); - ad.stop(); + ad.disconnect() } device.onConnectionStateChanged.subscribe { castConnectionState -> - Logger.i(TAG, "Active device connection state changed: $castConnectionState"); + Logger.i(TAG, "Active device connection state changed: $castConnectionState") if (castConnectionState == CastConnectionState.DISCONNECTED) { Logger.i(TAG, "Clearing events: $castConnectionState"); @@ -351,10 +178,14 @@ class StateCasting { synchronized(_castingDialogLock) { if(_currentDialog == null) { _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, - "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, + "Connecting to [${device.name}]", + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, UIDialogs.Action("Disconnect", { - device.stop(); + try { + device.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to disconnect from device: $e") + } })); } } @@ -376,7 +207,7 @@ class StateCasting { }; device.onPlayChanged.subscribe { invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; - } + }; device.onDurationChanged.subscribe { invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; }; @@ -388,7 +219,7 @@ class StateCasting { }; try { - device.start(); + device.connect(); } catch (e: Throwable) { Logger.w(TAG, "Failed to connect to device."); device.onConnectionStateChanged.clear(); @@ -399,52 +230,24 @@ class StateCasting { return; } - activeDevice = device; - Logger.i(TAG, "Connect to device ${device.name}"); + activeDevice = device + Logger.i(TAG, "Connect to device ${device.name}") } - fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { - val device = deviceFromCastingDeviceInfo(deviceInfo); - return addRememberedDevice(device); - } - - fun getRememberedCastingDevices(): List { - return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } - } - - fun getRememberedCastingDeviceNames(): List { - return _storage.getDeviceNames() - } - - fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { - val deviceInfo = device.getDeviceInfo() - return _storage.addDevice(deviceInfo) - } - - fun removeRememberedDevice(device: CastingDevice) { - val name = device.name ?: return - _storage.removeDevice(name) - } - - private fun invokeInMainScopeIfRequired(action: () -> Unit){ - if(Looper.getMainLooper().thread != Thread.currentThread()) { - _scopeMain.launch { action(); } - return; - } - - action(); - } - - fun cancel() { - _castId.incrementAndGet() + fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { + return Metadata( + title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() + ) } + @Throws suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean { return withContext(Dispatchers.IO) { val ad = activeDevice ?: return@withContext false; if (ad.connectionState != CastConnectionState.CONNECTED) { return@withContext false; } + val deviceProto = ad.protocolType val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; val castId = _castId.incrementAndGet() @@ -460,7 +263,7 @@ class StateCasting { if (sourceCount > 1) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - if (ad is AirPlayCastingDevice) { + if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as local HLS"); castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } else { @@ -468,16 +271,17 @@ class StateCasting { castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } } else { - val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource + val isRawDash = + videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource if (isRawDash) { Logger.i(TAG, "Casting as raw DASH"); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); } else { - if (ad is FCastCastingDevice) { + if (deviceProto == CastProtocolType.FCAST) { Logger.i(TAG, "Casting as DASH direct"); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else if (ad is AirPlayCastingDevice) { + } else if (deviceProto == CastProtocolType.AIRPLAY) { Logger.i(TAG, "Casting as HLS indirect"); castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else { @@ -495,27 +299,27 @@ class StateCasting { val videoPath = "/video-${id}" val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } else if (audioSource is IAudioUrlSource) { val audioPath = "/audio-${id}" val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); - } else if(videoSource is IHLSManifestSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); + } else if (videoSource is IHLSManifestSource) { + if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied HLS"); castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } - } else if(audioSource is IHLSManifestAudioSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { + } else if (audioSource is IHLSManifestAudioSource) { + if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) { Logger.i(TAG, "Casting as proxied audio HLS"); castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); } } else if (videoSource is LocalVideoSource) { Logger.i(TAG, "Casting as local video"); @@ -545,28 +349,69 @@ class StateCasting { fun resumeVideo(): Boolean { val ad = activeDevice ?: return false; - ad.resumeVideo(); + try { + ad.resumePlayback(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to resume playback: $e") + return false + } return true; } fun pauseVideo(): Boolean { val ad = activeDevice ?: return false; - ad.pauseVideo(); + try { + ad.pausePlayback(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to pause playback: $e") + return false + } return true; } fun stopVideo(): Boolean { val ad = activeDevice ?: return false; - ad.stopVideo(); + try { + ad.stopPlayback(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop playback: $e") + return false + } return true; } fun videoSeekTo(timeSeconds: Double): Boolean { val ad = activeDevice ?: return false; - ad.seekVideo(timeSeconds); + try { + ad.seekTo(timeSeconds); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to seek: $e") + return false + } return true; } + fun changeVolume(volume: Double): Boolean { + val ad = activeDevice ?: return false; + try { + ad.changeVolume(volume); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change volume: $e") + return false + } + return true; + } + + fun changeSpeed(speed: Double): Boolean { + val ad = activeDevice ?: return false; + try { + ad.changeSpeed(speed); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change speed: $e") + return false + } + return true; + } private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); @@ -581,7 +426,7 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(videoUrl); } @@ -600,7 +445,7 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(audioUrl); } @@ -696,7 +541,7 @@ class StateCasting { ).withTag("castLocalHls") Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") - ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed) + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) } @@ -721,10 +566,10 @@ class StateCasting { Logger.v(TAG) { "Dash manifest: $dashContent" }; _castServer.addHandlerWithAllowAllOptions( - HttpConstantHandler("GET", dashPath, dashContent, - "application/dash+xml") - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + HttpConstantHandler("GET", dashPath, dashContent, + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) @@ -745,7 +590,7 @@ class StateCasting { } Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); } @@ -810,12 +655,18 @@ class StateCasting { Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); Logger.v(TAG) { "Dash manifest: $content" }; - ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed); + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } - private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List { + private fun castProxiedHls( + video: IPlatformVideoDetails, + sourceUrl: String, + codec: String?, + resumePosition: Double, + speed: Double? + ): List { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); @@ -826,117 +677,151 @@ class StateCasting { val hlsUrl = url + hlsPath Logger.i(TAG, "HLS url: $hlsUrl"); - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext -> - _castServer.removeAllHandlers("castProxiedHlsVariant") + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", hlsPath + ) { masterContext -> + _castServer.removeAllHandlers("castProxiedHlsVariant") - val headers = masterContext.headers.clone() - headers["Content-Type"] = "application/vnd.apple.mpegurl"; + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; - val masterPlaylistResponse = _client.get(sourceUrl) - check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + val masterPlaylistResponse = _client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } - val masterPlaylistContent = masterPlaylistResponse.body?.string() - ?: throw Exception("Master playlist content is empty") + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") - val masterPlaylist: HLS.MasterPlaylist - try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) - } catch (e: Throwable) { - if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { - //This is a variant playlist, not a master playlist - Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + val masterPlaylist: HLS.MasterPlaylist + try { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + } catch (e: Throwable) { + if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { + //This is a variant playlist, not a master playlist + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); - val vpHeaders = masterContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - return@HttpFunctionHandler - } else { - throw e - } - } - - Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - - val newVariantPlaylistRefs = arrayListOf() - val newMediaRenditions = arrayListOf() - val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) - - for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - - val response = _client.get(variantPlaylistRef.url) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") - - newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( - newPlaylistUrl, - variantPlaylistRef.streamInfo - )) - } - - for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID() - - var newPlaylistUrl: String? = null - if (mediaRendition.uri != null) { - val newPlaylistPath = "/hls-playlist-${playlistId}" - newPlaylistUrl = url + newPlaylistPath - - _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() + val vpHeaders = masterContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val response = _client.get(mediaRendition.uri) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val vpContent = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - - val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val variantPlaylist = + HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + return@HttpFunctionHandler + } else { + throw e + } } - newMediaRenditions.add(HLS.MediaRendition( - mediaRendition.type, - newPlaylistUrl, - mediaRendition.groupID, - mediaRendition.language, - mediaRendition.name, - mediaRendition.isDefault, - mediaRendition.isAutoSelect, - mediaRendition.isForced - )) - } + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); - masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist( + newVariantPlaylistRefs, + newMediaRenditions, + masterPlaylist.sessionDataList, + masterPlaylist.independentSegments + ) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(variantPlaylistRef.url) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) + val proxiedVariantPlaylist = + proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + + newVariantPlaylistRefs.add( + HLS.VariantPlaylistReference( + newPlaylistUrl, variantPlaylistRef.streamInfo + ) + ) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID() + + var newPlaylistUrl: String? = null + if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + + _castServer.addHandlerWithAllowAllOptions( + HttpFunctionHandler( + "GET", newPlaylistPath + ) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val response = _client.get(mediaRendition.uri) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = + HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist( + url, playlistId, variantPlaylist, video.isLive + ) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + newMediaRenditions.add(HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + )) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed); + val hackfixResumePosition = + if (ad.protocolType == CastProtocolType.CHROMECAST && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; + ad.loadVideo( + if (video.isLive) "LIVE" else "BUFFERED", + "application/vnd.apple.mpegurl", + hlsUrl, + hackfixResumePosition, + video.duration.toDouble(), + speed, + metadataFromVideo(video) + ); return listOf(hlsUrl); } @@ -1110,14 +995,14 @@ class StateCasting { ).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true - return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier + return Settings.instance.casting.alwaysProxyRequests || castingDevice.protocolType != CastProtocolType.FCAST || hasRequestModifier } private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { @@ -1193,12 +1078,12 @@ class StateCasting { } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } - private fun cleanExecutors() { + fun cleanExecutors() { if (_videoExecutor != null) { _videoExecutor?.cleanup() _videoExecutor = null @@ -1397,163 +1282,61 @@ class StateCasting { } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video)); return listOf() } - private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice { - return when (deviceInfo.type) { - CastProtocolType.CHROMECAST -> { - ChromecastCastingDevice(deviceInfo); - } - CastProtocolType.AIRPLAY -> { - AirPlayCastingDevice(deviceInfo); - } - CastProtocolType.FCAST -> { - FCastCastingDevice(deviceInfo); - } - } + fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { + val device = deviceFromInfo(deviceInfo); + return addRememberedDevice(device); } - private fun addOrUpdateChromeCastDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.addresses = addresses; - d.port = port; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { + val deviceInfo = device.getDeviceInfo() + return _storage.addDevice(deviceInfo) } - private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { AirPlayCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromInfo(it) } } - private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { FCastCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } - - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; - } - - return@addOrUpdateCastDevice changed; - } - ); + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() } - private inline fun addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice { - var invokeEvents: (() -> Unit)? = null; - - synchronized(devices) { - val device = devices[name]; - if (device != null) { - if (device !is TCastDevice) { - Logger.w(TAG, "Device name conflict between device types. Ignoring device."); - } else { - val changed = deviceUpdater(device as TCastDevice); - if (changed) { - invokeEvents = { - onDeviceChanged.emit(device); - } - } else { - - } - } - } else { - val newDevice = deviceFactory(); - this.devices[name] = newDevice; - - invokeEvents = { - onDeviceAdded.emit(newDevice); - }; - } - } - - invokeEvents?.let { _scopeMain.launch { it(); }; }; + fun removeRememberedDevice(device: CastingDevice) { + val name = device.name ?: return + _storage.removeDevice(name) } - fun enableDeveloper(enableDev: Boolean){ + fun enableDeveloper(enableDev: Boolean) { _castServer.removeAllHandlers("dev"); - if(enableDev) { + if (enableDev) { _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> if (context.query.containsKey("dashUrl")) { val dashUrl = context.query["dashUrl"]; - val html = "
\n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - "
"; + val html = + "
\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
"; context.respondCode(200, html, "text/html"); } }).withTag("dev"); } } - @Serializable - private data class FCastNetworkConfig( - val name: String, - val addresses: List, - val services: List - ) - - @Serializable - private data class FCastService( - val port: Int, - val type: Int - ) - companion object { - val instance: StateCasting = StateCasting(); - - private val representationRegex = Regex("(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL) - private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); + var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) { + StateCastingExp() + } else { + StateCastingLegacy() + } + private val representationRegex = Regex( + "(.*?)<\\/Representation>", + RegexOption.DOT_MATCHES_ALL + ) + private val mediaInitializationRegex = + Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); private val TAG = "StateCasting"; } -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCastingExp.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCastingExp.kt new file mode 100644 index 00000000..0c908074 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCastingExp.kt @@ -0,0 +1,174 @@ +package com.futo.platformplayer.casting + +import android.content.Context +import android.util.Log +import com.futo.platformplayer.BuildConfig +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo +import org.fcast.sender_sdk.ProtocolType +import org.fcast.sender_sdk.CastContext +import org.fcast.sender_sdk.NsdDeviceDiscoverer + +class StateCastingExp : StateCasting() { + private val _context = CastContext() + var _deviceDiscoverer: NsdDeviceDiscoverer? = null + + class DiscoveryEventHandler( + private val onDeviceAdded: (RsDeviceInfo) -> Unit, + private val onDeviceRemoved: (String) -> Unit, + private val onDeviceUpdated: (RsDeviceInfo) -> Unit, + ) : org.fcast.sender_sdk.DeviceDiscovererEventHandler { + override fun deviceAvailable(deviceInfo: RsDeviceInfo) { + onDeviceAdded(deviceInfo) + } + + override fun deviceChanged(deviceInfo: RsDeviceInfo) { + onDeviceUpdated(deviceInfo) + } + + override fun deviceRemoved(deviceName: String) { + onDeviceRemoved(deviceName) + } + } + + init { + if (BuildConfig.DEBUG) { + org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG) + } + } + + override fun handleUrl(url: String) { + try { + val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!! + val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo) + connectDevice(CastingDeviceExp(foundDevice)) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle URL: $e") + } + } + + override fun onStop() { + val ad = activeDevice ?: return + _resumeCastingDevice = ad.getDeviceInfo() + Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") + Logger.i(TAG, "Stopping active device because of onStop.") + try { + ad.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to disconnect from device: $e") + } + } + + @Synchronized + override fun start(context: Context) { + if (_started) + return + _started = true + + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null + + Logger.i(TAG, "CastingService starting...") + + _castServer.start() + enableDeveloper(true) + + Logger.i(TAG, "CastingService started.") + + _deviceDiscoverer = NsdDeviceDiscoverer( + context, + DiscoveryEventHandler( + { deviceInfo -> // Added + Logger.i(TAG, "Device added: ${deviceInfo.name}") + val device = _context.createDeviceFromInfo(deviceInfo) + val deviceHandle = CastingDeviceExp(device) + devices[deviceHandle.device.name()] = deviceHandle + invokeInMainScopeIfRequired { + onDeviceAdded.emit(deviceHandle) + } + }, + { deviceName -> // Removed + invokeInMainScopeIfRequired { + if (devices.containsKey(deviceName)) { + val device = devices.remove(deviceName) + if (device != null) { + onDeviceRemoved.emit(device) + } + } + } + }, + { deviceInfo -> // Updated + Logger.i(TAG, "Device updated: $deviceInfo") + val handle = devices[deviceInfo.name] + if (handle != null && handle is CastingDeviceExp) { + handle.device.setPort(deviceInfo.port) + handle.device.setAddresses(deviceInfo.addresses) + invokeInMainScopeIfRequired { + onDeviceChanged.emit(handle) + } + } + }, + ) + ) + } + + @Synchronized + override fun stop() { + if (!_started) { + return + } + + _started = false + + Logger.i(TAG, "CastingService stopping.") + + _scopeIO.cancel() + _scopeMain.cancel() + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice + activeDevice = null + try { + d?.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to disconnect device: $e") + } + + _castServer.stop() + _castServer.removeAllHandlers() + + Logger.i(TAG, "CastingService stopped.") + + _deviceDiscoverer = null + } + + override fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, + setTime: (Long) -> Unit + ): Job? = null + + override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp { + val rsAddrs = + deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws! + val rsDeviceInfo = RsDeviceInfo( + name = deviceInfo.name, + protocol = when (deviceInfo.type) { + com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST + com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST + else -> throw IllegalArgumentException() + }, + addresses = rsAddrs, + port = deviceInfo.port.toUShort(), + ) + + return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo)) + } + + companion object { + private val TAG = "StateCastingExp" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCastingLegacy.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCastingLegacy.kt new file mode 100644 index 00000000..735f992e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCastingLegacy.kt @@ -0,0 +1,397 @@ +package com.futo.platformplayer.casting + +import android.content.Context +import android.net.Uri +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Base64 +import android.util.Log +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.InetAddress +import kotlinx.coroutines.delay + +class StateCastingLegacy : StateCasting() { + private var _nsdManager: NsdManager? = null + + private val _discoveryListeners = mapOf( + "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), + "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), + "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), + "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) + ) + + override fun handleUrl(url: String) { + val uri = Uri.parse(url) + if (uri.scheme != "fcast") { + throw Exception("Expected scheme to be FCast") + } + + val type = uri.host + if (type != "r") { + throw Exception("Expected type r") + } + + val connectionInfo = uri.pathSegments[0] + val json = + Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + .toString(Charsets.UTF_8) + val networkConfig = Json.decodeFromString(json) + val tcpService = networkConfig.services.first { v -> v.type == 0 } + + val foundInfo = addRememberedDevice( + CastingDeviceInfo( + name = networkConfig.name, + type = CastProtocolType.FCAST, + addresses = networkConfig.addresses.toTypedArray(), + port = tcpService.port + ) + ) + + connectDevice(deviceFromInfo(foundInfo)) + } + + override fun onStop() { + val ad = activeDevice ?: return; + _resumeCastingDevice = ad.getDeviceInfo() + Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") + Logger.i(TAG, "Stopping active device because of onStop."); + ad.disconnect(); + } + + @Synchronized + override fun start(context: Context) { + if (_started) + return; + _started = true; + + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null; + + Logger.i(TAG, "CastingService starting..."); + + _castServer.start(); + enableDeveloper(true); + + Logger.i(TAG, "CastingService started."); + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + startDiscovering() + } + + @Synchronized + private fun startDiscovering() { + _nsdManager?.apply { + _discoveryListeners.forEach { + discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) + } + } + } + + @Synchronized + private fun stopDiscovering() { + _nsdManager?.apply { + _discoveryListeners.forEach { + try { + stopServiceDiscovery(it.value) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + } + } + + @Synchronized + override fun stop() { + if (!_started) + return; + + _started = false; + + Logger.i(TAG, "CastingService stopping.") + + stopDiscovering() + _scopeIO.cancel(); + _scopeMain.cancel(); + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice; + activeDevice = null; + d?.disconnect(); + + _castServer.stop(); + _castServer.removeAllHandlers(); + + Logger.i(TAG, "CastingService stopped.") + + _nsdManager = null + } + + private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { + return object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + arrayOf(service.host) + } + addOrUpdate(service.serviceName, addresses, service.port) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback( + service, + { it.run() }, + object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate( + serviceInfo.serviceName, + serviceInfo.hostAddresses.toTypedArray(), + serviceInfo.port + ) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate( + serviceInfo.serviceName, + arrayOf(serviceInfo.host), + serviceInfo.port + ) + } + }) + } + } + } + } + + override fun startUpdateTimeJob( + onTimeJobTimeChanged_s: Event1, + setTime: (Long) -> Unit + ): Job? { + val d = activeDevice; + if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) { + return _scopeMain.launch { + while (true) { + val device = instance.activeDevice + if (device == null || !device.isPlaying) { + break + } + + delay(1000) + val time_ms = (device.expectedCurrentTime * 1000.0).toLong() + setTime(time_ms) + onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) + } + } + } + return null + } + + override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice { + return CastingDeviceLegacyWrapper( + when (deviceInfo.type) { + CastProtocolType.CHROMECAST -> { + ChromecastCastingDevice(deviceInfo); + } + + CastProtocolType.AIRPLAY -> { + AirPlayCastingDevice(deviceInfo); + } + + CastProtocolType.FCAST -> { + FCastCastingDevice(deviceInfo); + } + } + ) + } + + private fun addOrUpdateChromeCastDevice( + name: String, + addresses: Array, + port: Int + ) { + return addOrUpdateCastDevice( + name, + deviceFactory = { + CastingDeviceLegacyWrapper( + ChromecastCastingDevice( + name, + addresses, + port + ) + ) + }, + deviceUpdater = { d -> + if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.addresses = addresses; + d.inner.port = port; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice( + name, + deviceFactory = { + CastingDeviceLegacyWrapper( + AirPlayCastingDevice( + name, + addresses, + port + ) + ) + }, + deviceUpdater = { d -> + if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.port = port; + d.inner.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice( + name, + deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) }, + deviceUpdater = { d -> + if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) { + return@addOrUpdateCastDevice false; + } + + val changed = + addresses.contentEquals(addresses) || d.name != name || d.inner.port != port; + if (changed) { + d.inner.name = name; + d.inner.port = port; + d.inner.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private inline fun addOrUpdateCastDevice( + name: String, + deviceFactory: () -> CastingDevice, + deviceUpdater: (device: CastingDevice) -> Boolean + ) { + var invokeEvents: (() -> Unit)? = null; + + synchronized(devices) { + val device = devices[name]; + if (device != null) { + val changed = deviceUpdater(device); + if (changed) { + invokeEvents = { + onDeviceChanged.emit(device); + } + } + } else { + val newDevice = deviceFactory(); + this.devices[name] = newDevice + + invokeEvents = { + onDeviceAdded.emit(newDevice); + }; + } + } + + invokeEvents?.let { _scopeMain.launch { it(); }; }; + } + + @Serializable + private data class FCastNetworkConfig( + val name: String, + val addresses: List, + val services: List + ) + + @Serializable + private data class FCastService( + val port: Int, + val type: Int + ) + + companion object { + private val TAG = "StateCastingLegacy" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 9eb71145..ea8029ce 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -8,11 +8,13 @@ import android.view.View import android.view.WindowManager import android.widget.* import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toInetAddress +import com.futo.platformplayer.logging.Logger class CastingAddDialog(context: Context?) : AlertDialog(context) { @@ -38,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _buttonConfirm = findViewById(R.id.button_confirm); _buttonTutorial = findViewById(R.id.button_tutorial) - ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter -> + val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) { + R.array.exp_casting_device_type_array + } else { + R.array.casting_device_type_array + } + + ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter -> adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); _spinnerType.adapter = adapter; }; @@ -101,7 +109,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { _textError.visibility = View.GONE; val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); - StateCasting.instance.addRememberedDevice(castingDeviceInfo); + try { + StateCasting.instance.addRememberedDevice(castingDeviceInfo) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to add remembered device: $e") + } performDismiss(); }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 2bb87111..28f2f989 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.Button -import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -18,7 +17,6 @@ import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { synchronized(StateCasting.instance.devices) { _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) } - _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) + updateUnifiedList() StateCasting.instance.onDeviceAdded.subscribe(this) { d -> val name = d.name - if (name != null) + if (name != null) { _devices.add(name) - updateUnifiedList() + updateUnifiedList() + } } StateCasting.instance.onDeviceChanged.subscribe(this) { d -> diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 862f8333..90f09a05 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -12,12 +12,11 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.casting.AirPlayCastingDevice import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice -import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.logging.Logger @@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonPlay = findViewById(R.id.button_play); _buttonPlay.setOnClickListener { - StateCasting.instance.activeDevice?.resumeVideo() + StateCasting.instance.resumeVideo() } _buttonPause = findViewById(R.id.button_pause); _buttonPause.setOnClickListener { - StateCasting.instance.activeDevice?.pauseVideo() + StateCasting.instance.pauseVideo() } _buttonStop = findViewById(R.id.button_stop); _buttonStop.setOnClickListener { (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() - StateCasting.instance.activeDevice?.stopVideo() + StateCasting.instance.stopVideo() } _buttonNext = findViewById(R.id.button_next); @@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _buttonClose.setOnClickListener { dismiss(); }; _buttonDisconnect.setOnClickListener { - StateCasting.instance.activeDevice?.stopCasting(); + try { + StateCasting.instance.activeDevice?.disconnect() + } catch (e: Throwable) { + Logger.e(TAG, "Active device failed to disconnect: $e") + } dismiss(); }; @@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - try { - activeDevice.seekVideo(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); - } + StateCasting.instance.videoSeekTo(value.toDouble()) }); //TODO: Check if volume slider is properly hidden in all cases @@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { return@OnChangeListener } - val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; - if (activeDevice.canSetVolume) { - try { - activeDevice.changeVolume(value.toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to change volume.", e); - } - } + StateCasting.instance.changeVolume(value.toDouble()) }); setLoading(false); @@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { private fun updateDevice() { val d = StateCasting.instance.activeDevice ?: return; - if (d is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FastCast"; + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + CastProtocolType.AIRPLAY -> { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } + CastProtocolType.FCAST -> { + _imageDevice.setImageResource( + if (Settings.instance.casting.experimentalCasting) { + R.drawable.ic_exp_fc + } else { + R.drawable.ic_fc + } + ) + _textType.text = "FCast"; + } } _textName.text = d.name; @@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur) _sliderPosition.valueTo = dur - if (d.canSetVolume) { + if (d.canSetVolume()) { _layoutVolumeAdjustable.visibility = View.VISIBLE; _layoutVolumeFixed.visibility = View.GONE; } else { @@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { CastConnectionState.CONNECTED -> { enableControls(interactiveControls) } - CastConnectionState.CONNECTING, - CastConnectionState.DISCONNECTED -> { + CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> { disableControls(interactiveControls) } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 0818f9ed..317373e7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -576,9 +576,8 @@ class VideoDetailView : ConstraintLayout { if(chapter?.type == ChapterType.SKIPPABLE) { _layoutSkip.visibility = VISIBLE; } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) { - val ad = StateCasting.instance.activeDevice - if (ad != null) { - ad.seekVideo(chapter.timeEnd) + if (StateCasting.instance.activeDevice != null) { + StateCasting.instance.videoSeekTo(chapter.timeEnd) } else { _player.seekTo((chapter.timeEnd * 1000).toLong()); } @@ -886,7 +885,7 @@ class VideoDetailView : ConstraintLayout { if (ad != null) { val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong()); if(currentChapter?.type == ChapterType.SKIPPABLE) { - ad.seekVideo(currentChapter.timeEnd); + StateCasting.instance.videoSeekTo(currentChapter.timeEnd); } } else { val currentChapter = _player.getCurrentChapter(_player.position); @@ -2368,11 +2367,11 @@ class VideoDetailView : ConstraintLayout { ?.distinct() ?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); - val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true + val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( - R.string.quality), null, true, + R.string.quality), null, true, qualityPlaybackSpeedTitle, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); @@ -2393,7 +2392,7 @@ class VideoDetailView : ConstraintLayout { val newPlaybackSpeed = playbackSpeedString.toDouble(); if (_isCasting) { val ad = StateCasting.instance.activeDevice ?: return@subscribe - if (!ad.canSetSpeed) { + if (!ad.canSetSpeed()) { return@subscribe } diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt index a530e415..4bb5fa5d 100644 --- a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -3,16 +3,9 @@ package com.futo.platformplayer.models import com.futo.platformplayer.casting.CastProtocolType @kotlinx.serialization.Serializable -class CastingDeviceInfo { - var name: String; - var type: CastProtocolType; - var addresses: Array; - var port: Int; - - constructor(name: String, type: CastProtocolType, addresses: Array, port: Int) { - this.name = name; - this.type = type; - this.addresses = addresses; - this.port = port; - } -} \ No newline at end of file +class CastingDeviceInfo( + var name: String, + var type: CastProtocolType, + var addresses: Array, + var port: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 133dd26b..32fb5367 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -4,21 +4,19 @@ import android.graphics.drawable.Animatable import android.view.View import android.widget.FrameLayout import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R -import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice -import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 -import androidx.core.view.isVisible -import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder { val connect = { device?.let { dev -> - if (dev.isReady) { - StateCasting.instance.activeDevice?.stopCasting() - StateCasting.instance.connectDevice(dev) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored + try { + if (dev.isReady) { + StateCasting.instance.activeDevice?.stopPlayback() + StateCasting.instance.connectDevice(dev) + onConnect.emit(dev) + } else { + view.context?.let { + UIDialogs.toast(it, "Device not ready, may be offline") + } } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to connect: $e") } } } @@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder { } fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { - if (d is ChromecastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_chromecast); - _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_airplay); - _textType.text = "AirPlay"; - } else if (d is FCastCastingDevice) { - _imageDevice.setImageResource(R.drawable.ic_fc); - _textType.text = "FCast"; + when (d.protocolType) { + CastProtocolType.CHROMECAST -> { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } + CastProtocolType.AIRPLAY -> { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } + CastProtocolType.FCAST -> { + _imageDevice.setImageResource( + if (Settings.instance.casting.experimentalCasting) { + R.drawable.ic_exp_fc + } else { + R.drawable.ic_fc + } + ) + _textType.text = "FCast"; + } } _textName.text = d.name; @@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder { device = d; } + + companion object { + private val TAG = "DeviceViewHolder" + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index f187230c..acffc619 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -2,12 +2,7 @@ package com.futo.platformplayer.views.casting import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.TextView import androidx.core.content.ContextCompat import com.futo.platformplayer.R import com.futo.platformplayer.Settings diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 161f3dc3..4dc307b5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -21,14 +21,13 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.casting.AirPlayCastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice +import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.formatDuration -import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView @@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch class CastView : ConstraintLayout { @@ -99,19 +97,30 @@ class CastView : ConstraintLayout { val d = StateCasting.instance.activeDevice ?: return@subscribe; _speedHoldWasPlaying = d.isPlaying _speedHoldPrevRate = d.speed - if (d.canSetSpeed) - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - d.resumeVideo() + try { + if (d.canSetSpeed()) { + d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) + } + d.resumePlayback() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e") + } } _gestureControlView.onSpeedHoldEnd.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - if (!_speedHoldWasPlaying) d.pauseVideo() - d.changeSpeed(_speedHoldPrevRate) + try { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + if (!_speedHoldWasPlaying) { + d.pausePlayback() + } + d.changeSpeed(_speedHoldPrevRate) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e") + } } _gestureControlView.onSeek.subscribe { val d = StateCasting.instance.activeDevice ?: return@subscribe; - StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000); }; _buttonLoop.setOnClickListener { @@ -220,22 +229,9 @@ class CastView : ConstraintLayout { stopTimeJob() if(isPlaying) { - val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { - _updateTimeJob = _scope.launch { - while (true) { - val device = StateCasting.instance.activeDevice; - if (device == null || !device.isPlaying) { - break; - } - - delay(1000); - val time_ms = (device.expectedCurrentTime * 1000.0).toLong() - setTime(time_ms); - onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) - } - } - } + StateCasting.instance.startUpdateTimeJob( + onTimeJobTimeChanged_s + ) { setTime(it) } if (!_inPictureInPicture) { _buttonPause.visibility = View.VISIBLE; @@ -333,4 +329,8 @@ class CastView : ConstraintLayout { _loaderGame.visibility = View.VISIBLE _loaderGame.startLoader(expectedDurationMs.toLong()) } + + companion object { + private val TAG = "CastView"; + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exp_fc.xml b/app/src/main/res/drawable/ic_exp_fc.xml new file mode 100644 index 00000000..355f8836 --- /dev/null +++ b/app/src/main/res/drawable/ic_exp_fc.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bc87252..92c52cf4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,8 @@ If casting over IPV6 is allowed, can cause issues on some networks Allow Link Local IPV4 If casting over IPV4 link local is allowed, can cause issues on some networks + Experimental + Use experimental casting backend (requires restart) Discover Find new video sources to add These sources have been disabled @@ -1104,6 +1106,10 @@ ChromeCast AirPlay
+ + FCast + ChromeCast + None Error From 7597f5136c306904ad3a991b749b708938079f06 Mon Sep 17 00:00:00 2001 From: Koen J Date: Fri, 26 Sep 2025 13:46:43 +0200 Subject: [PATCH 39/46] Fix Android getting stuck. --- app/src/main/java/com/futo/platformplayer/Extensions_V8.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt index fc1f5cf3..220802b0 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -204,8 +204,12 @@ fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { override fun onFulfilled(p0: V8Value?) { if(p0 is V8ValueError) promiseException = ScriptExecutionException(plugin.config, p0.message); - else + else { + if (p0 is V8ValueObject) { + p0.setWeak() + } promiseResult = p0 as T; + } latch.countDown(); } override fun onRejected(p0: V8Value?) { From 1ef566ab16a6350547f8492752acbafafb607aaf Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 29 Sep 2025 12:31:17 +0200 Subject: [PATCH 40/46] Async fixes, local file playback support --- app/src/main/assets/scripts/source.js | 1 + .../com/futo/platformplayer/Extensions_V8.kt | 49 +++++-- .../platformplayer/activities/MainActivity.kt | 16 ++- .../LocalVideoUnMuxedSourceDescriptor.kt | 20 ++- .../models/streams/sources/AudioUrlSource.kt | 3 +- .../models/streams/sources/VideoUrlSource.kt | 3 +- .../models/video/LocalPlatformVideoDetails.kt | 122 ++++++++++++++++++ .../models/LocalVideoMuxedSourceDescriptor.kt | 18 ++- .../models/sources/LocalAudioContentSource.kt | 33 +++++ .../models/sources/LocalAudioFileSource.kt | 34 +++++ .../models/sources/LocalVideoContentSource.kt | 33 +++++ .../models/sources/LocalVideoFileSource.kt | 3 + .../futo/platformplayer/engine/V8Plugin.kt | 2 + .../views/video/FutoVideoPlayerBase.kt | 42 ++++++ app/src/stable/assets/sources/odysee | 2 +- app/src/stable/assets/sources/rumble | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/AndroidManifest.xml | 10 ++ app/src/unstable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 23 files changed, 376 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioFileSource.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 4156abca..821fa656 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -67,6 +67,7 @@ class ScriptException extends Error { super(arguments[0]); this.plugin_type = "ScriptException"; this.message = arguments[0]; + this.msg = arguments[0]; } else { super(msg); diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt index fc1f5cf3..e08cb7e9 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -194,7 +194,6 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap { return map; } - fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { val latch = CountDownLatch(1); var promiseResult: T? = null; @@ -204,16 +203,19 @@ fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { override fun onFulfilled(p0: V8Value?) { if(p0 is V8ValueError) promiseException = ScriptExecutionException(plugin.config, p0.message); - else + else { + if(p0 is V8ValueObject) + p0.setWeak(); promiseResult = p0 as T; + } latch.countDown(); } override fun onRejected(p0: V8Value?) { - promiseException = (NotImplementedError("onRejected promise not implemented..")); + promiseException = p0?.toException(plugin.config); latch.countDown(); } override fun onCatch(p0: V8Value?) { - promiseException = (NotImplementedError("onCatch promise not implemented..")); + promiseException = p0?.toException(plugin.config); latch.countDown(); } }); @@ -223,8 +225,25 @@ fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { promiseException = CancellationException("Cancelled by system"); latch.countDown(); } - plugin.unbusy { - latch.await(); + //Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString()); + + + if(!promise.isPending) { + try { + Logger.i("V8", "V8Promise resolved synchronously"); + if(promise.isFulfilled) + promiseResult = promise.getResult(); + else + promiseException = promise.getResult().toException(plugin.config); + } + catch(ex: Throwable) { + promiseException = ex; + } + } + else { + plugin.unbusy { + latch.await(); + } } if(promiseException != null) throw promiseException!!; @@ -250,11 +269,11 @@ fun V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred } override fun onRejected(p0: V8Value?) { plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); + underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..")); } override fun onCatch(p0: V8Value?) { plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); + underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..")); } }); } @@ -265,6 +284,20 @@ fun V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred return def; } +fun V8Value.toException(config: IV8PluginConfig): Throwable { + val p0 = this; + if(p0 is V8ValueObject) { + val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" } + val msg = p0.getOrDefault(config, "msg", "Promise Exception", null) + ?: p0.getOrDefault(config, "message", "Promise Exception", ""); + return Exception("Promise Failed: " + pluginType + msg); + } + else if(p0 is V8ValueString) + return Exception("Promise Failed:" + p0.value); + else + return NotImplementedError("onCatch promise not implemented.."); +} + class V8Deferred(val deferred: Deferred, val estDuration: Int = -1): Deferred by deferred { fun convert(conversion: (result: T)->R): V8Deferred{ diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 5e4e1e42..d824d18c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -39,6 +39,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.video.LocalVideoDetails import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp @@ -768,7 +769,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (targetData != null) { lifecycleScope.launch(Dispatchers.Main) { try { - handleUrlAll(targetData) + handleUrlAll(targetData, intent) } catch (e: Throwable) { Logger.e(TAG, "Unhandled exception in handleUrlAll", e) } @@ -779,8 +780,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } - suspend fun handleUrlAll(url: String) { + suspend fun handleUrlAll(url: String, openIntent: Intent? = null) { val uri = Uri.parse(url) + val intent = openIntent ?: this.intent; when (uri.scheme) { "grayjay" -> { if (url.startsWith("grayjay://license/")) { @@ -807,11 +809,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } "content" -> { - if (!handleContent(url, intent.type)) { + if (!handleContent(url, intent?.type)) { UIDialogs.showSingleButtonDialog( this, R.drawable.ic_play, - getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]", + getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]", "Ok", { }); } @@ -932,6 +934,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } else if (file.lowercase().endsWith(".txt") || mime == "text/plain") { return handleUnknownText(String(data)); } + else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) { + val mediaItem = LocalVideoDetails.fromContent(file, mime); + navigateWhenReady(_fragVideoDetail, mediaItem); + return true; + } + return false; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt index b5d28c6f..9d923d60 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt @@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource import com.futo.platformplayer.downloads.VideoLocal -class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() { - override val videoSources: Array get() = video.videoSource.toTypedArray(); - override val audioSources: Array get() = video.audioSource.toTypedArray(); +class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor { + override val videoSources: Array; + override val audioSources: Array; + + constructor(video: VideoLocal) { + videoSources = video.videoSource.toTypedArray(); + audioSources = video.audioSource.toTypedArray(); + } + constructor(audio: LocalAudioContentSource) { + videoSources = arrayOf() + audioSources = arrayOf(audio); + } + constructor(videoSources: Array, audioSources: Array) { + this.videoSources = videoSources; + this.audioSources = audioSources; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt index a4d2cb55..9c5075b4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt @@ -14,7 +14,8 @@ class AudioUrlSource( override val language: String = Language.UNKNOWN, override val duration: Long? = null, override var priority: Boolean = false, - override var original: Boolean = false + override var original: Boolean = false, + var isLocal: Boolean = false ) : IAudioUrlSource, IStreamMetaDataSource{ override var streamMetaData: StreamMetaData? = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt index 490b8d4c..ebc112ec 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt @@ -14,7 +14,8 @@ open class VideoUrlSource( override val codec : String = "", override val bitrate : Int? = 0, - override var priority: Boolean = false + override var priority: Boolean = false, + var isLocal: Boolean = false ) : IVideoUrlSource, IStreamMetaDataSource { override var streamMetaData: StreamMetaData? = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt new file mode 100644 index 00000000..52659b46 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt @@ -0,0 +1,122 @@ +package com.futo.platformplayer.api.media.models.video + +import android.annotation.SuppressLint +import android.net.Uri +import android.provider.MediaStore +import android.provider.OpenableColumns +import androidx.core.net.toUri +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.others.Language +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.states.StateApp +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +open class LocalVideoDetails( + override val id: PlatformID, + override val name: String, + override val thumbnails: Thumbnails, + override val author: PlatformAuthorLink, + override val url: String, + override val duration: Long, + + val mimeType: String? = null, + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val datetime: OffsetDateTime? +) : IPlatformVideo, IPlatformVideoDetails { + final override val contentType: ContentType get() = ContentType.MEDIA; + + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + + override val isLive: Boolean get() = false; + + override val dash: IDashManifestSource? get() = null; + override val hls: IHLSManifestSource? get() = null; + override val live: IVideoSource? get() = null; + + + override val shareUrl: String = "" + override val viewCount: Long = -1 + override val rating: IRating = RatingLikes(0) + override val description: String = ""; + override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false) + (LocalVideoUnMuxedSourceDescriptor( + arrayOf(), + arrayOf(LocalAudioContentSource(url, mimeType ?: "", name)) + )) + else (LocalVideoMuxedSourceDescriptor( + LocalVideoContentSource(url, mimeType ?: "", name) + )) + ); + override val preview: ISerializedVideoSourceDescriptor? = null; + + override val subtitles: List = listOf() + override val isShort: Boolean = false + + fun toJson() : String { + return Json.encodeToString(this); + } + fun fromJson(str : String) : SerializedPlatformVideoDetails { + return Serializer.json.decodeFromString(str); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + + companion object { + fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails { + if(filePath.startsWith("content://")) + return fromContent(filePath, mimeType); + + return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1), + name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null); + } + fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails { + var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File"; + + return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1), + nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null); + } + + @SuppressLint("Range") + private fun getFileNameFromContentUrl(url: String): String? { + val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null); + cursor?.moveToFirst(); + val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + cursor?.close(); + return fileName; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt index da8ae431..0170f2fa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt @@ -1,13 +1,23 @@ package com.futo.platformplayer.api.media.platforms.local.models import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource import com.futo.platformplayer.downloads.VideoLocal -class LocalVideoMuxedSourceDescriptor( - private val video: LocalVideoFileSource -) : VideoMuxedSourceDescriptor() { - override val videoSources: Array get() = arrayOf(video); +class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor { + override val videoSources: Array; + + constructor(video: LocalVideoFileSource) { + videoSources = arrayOf(video); + } + constructor(video: LocalVideoContentSource) { + videoSources = arrayOf(video); + } + constructor(videoSources: Array) { + this.videoSources = videoSources; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt new file mode 100644 index 00000000..06f1c50c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.others.Language +import java.io.File + +class LocalAudioContentSource : IAudioSource { + + override val name: String; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + override val language: String = Language.UNKNOWN + override val original: Boolean = false; + + var contentUrl: String; + + constructor(contentUrl: String, mime: String, name: String? = null) { + this.name = name ?: "File"; + container = mime; + duration = 0; + + this.contentUrl = contentUrl; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioFileSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioFileSource.kt new file mode 100644 index 00000000..ae822837 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioFileSource.kt @@ -0,0 +1,34 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.others.Language +import java.io.File + +class LocalAudioFileSource: IAudioSource { + + + override val name: String; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + override val language: String = Language.UNKNOWN; + override val original: Boolean = false; + + var file: File; + + constructor(file: File) { + this.file = file; + name = file.name; + container = VideoHelper.videoExtensionToMimetype(file.extension) ?: ""; + duration = 0; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt new file mode 100644 index 00000000..d8507fab --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import java.io.File + +class LocalVideoContentSource: IVideoSource { + + + override val name: String; + override val width: Int; + override val height: Int; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + + var contentUrl: String; + + constructor(contentUrl: String, mime: String, name: String? = null) { + this.name = name ?: "File"; + width = 0; + height = 0; + container = mime; + duration = 0; + this.contentUrl = contentUrl; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt index 9e2f7792..4b4ff583 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt @@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource { override val duration: Long; override val priority: Boolean = false; + var file: File; + constructor(file: File) { + this.file = file; name = file.name; width = 0; height = 0; diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 9b888bff..9b350458 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -242,10 +242,12 @@ class V8Plugin { } fun busy(handle: ()->T): T { _busyLock.lock(); + //Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString()) try { return handle(); } finally { + //Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString()) _busyLock.unlock(); } /* diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index ff147b65..37cbe052 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -64,6 +64,10 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioFileSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 @@ -480,6 +484,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; } is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } + is LocalVideoFileSource -> { swapVideoSourceLocalFile(videoSource); true; } + is LocalVideoContentSource -> { swapVideoSourceLocalContent(videoSource); true; } null -> { _lastVideoMediaSource = null; true;} else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]"); } @@ -496,6 +502,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId); is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } + is LocalAudioFileSource -> { swapAudioSourceLocalFile(audioSource); true; } + is LocalAudioContentSource -> { swapAudioSourceLocalContent(audioSource); true; } null -> { _lastAudioMediaSource = null; true; } else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]"); } @@ -514,6 +522,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); } @OptIn(UnstableApi::class) + private fun swapVideoSourceLocalFile(videoSource: LocalVideoFileSource) { + Logger.i(TAG, "Loading VideoSource [Local]"); + val file = videoSource.file; + if(!file.exists()) + throw IllegalArgumentException("File for this video does not exist"); + _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); + } + @OptIn(UnstableApi::class) + private fun swapVideoSourceLocalContent(videoSource: LocalVideoContentSource) { + Logger.i(TAG, "Loading VideoSource [Local]"); + if(!videoSource.contentUrl.startsWith("content://")) + throw IllegalArgumentException("Not a content uri"); + _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(videoSource.contentUrl)); + } + @OptIn(UnstableApi::class) private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) { Logger.i(TAG, "Loading JSVideoUrlRangeSource"); if(videoSource.hasItag) { @@ -707,6 +732,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); } @OptIn(UnstableApi::class) + private fun swapAudioSourceLocalFile(audioSource: LocalAudioFileSource) { + Logger.i(TAG, "Loading VideoSource [Local]"); + val file = audioSource.file; + if(!file.exists()) + throw IllegalArgumentException("File for this video does not exist"); + _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); + } + @OptIn(UnstableApi::class) + private fun swapAudioSourceLocalContent(audioSource: LocalAudioContentSource) { + Logger.i(TAG, "Loading VideoSource [Local]"); + if(!audioSource.contentUrl.startsWith("content://")) + throw IllegalArgumentException("Not a content uri"); + _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(audioSource.contentUrl)); + } + @OptIn(UnstableApi::class) private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) { Logger.i(TAG, "Loading JSAudioUrlRangeSource"); if(audioSource.hasItag) { diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index 736c6b95..89ad7e9a 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 736c6b953a4613145e32010ff5ee5b08be1baac6 +Subproject commit 89ad7e9a4bae727164099fbd853f031c4902b674 diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index 3368dfaa..3a7087cc 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 3368dfaa2ccaaa060cbbc0e91c86200e4d927b6e +Subproject commit 3a7087ccb0a8626e354e80673fb1e73d1527175b diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index 048acef1..49db9e3e 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit 048acef152823d2621da177d3b4e1535cf4ca8ac +Subproject commit 49db9e3e15725c2e58c1fca565922aea738230d1 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index f1465628..72c0cf18 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit f1465628ecb67c9bceb96aed667355e6ddbb49d3 +Subproject commit 72c0cf181dede3e41846c8f017ce061ff1fcd231 diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index ba96bb29..dae2026f 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -8,6 +8,16 @@ + + + + + + + + + + diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index 736c6b95..89ad7e9a 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 736c6b953a4613145e32010ff5ee5b08be1baac6 +Subproject commit 89ad7e9a4bae727164099fbd853f031c4902b674 diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index 3368dfaa..3a7087cc 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 3368dfaa2ccaaa060cbbc0e91c86200e4d927b6e +Subproject commit 3a7087ccb0a8626e354e80673fb1e73d1527175b diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index 048acef1..49db9e3e 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit 048acef152823d2621da177d3b4e1535cf4ca8ac +Subproject commit 49db9e3e15725c2e58c1fca565922aea738230d1 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index f1465628..72c0cf18 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit f1465628ecb67c9bceb96aed667355e6ddbb49d3 +Subproject commit 72c0cf181dede3e41846c8f017ce061ff1fcd231 From 77348b37870500fdcae4dee1cdec2c0701677770 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 29 Sep 2025 13:06:42 +0200 Subject: [PATCH 41/46] Refs --- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 72c0cf18..c3a31f65 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 72c0cf181dede3e41846c8f017ce061ff1fcd231 +Subproject commit c3a31f6510659362e28607e85d4e8047c403c14c diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 72c0cf18..c3a31f65 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 72c0cf181dede3e41846c8f017ce061ff1fcd231 +Subproject commit c3a31f6510659362e28607e85d4e8047c403c14c From 817c90f3af125b58f19111175be757fc2b6dd751 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 29 Sep 2025 13:15:06 +0200 Subject: [PATCH 42/46] Translation fix. --- app/src/main/res/values-it/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 263067ba..a5783903 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -311,7 +311,6 @@ Aggiornamento in Secondo Piano Download in Secondo Piano Backup - Navigazione Concorrenza ByteRange Download ByteRange Trasmissione @@ -332,7 +331,6 @@ Cancella Pagamento Cancella i cookie quando effettui la disconnessione Cancella i cookie del browser in-app - Configura il comportamento di navigazione Barra minutaggio Configura se la barra del minutaggio cronologica deve essere mostrate Configurazione trasmissione From 986652adabe3ef12a0c22163ba589f02bab3b898 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 29 Sep 2025 15:01:04 +0200 Subject: [PATCH 43/46] Refs --- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index c3a31f65..07101fbc 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit c3a31f6510659362e28607e85d4e8047c403c14c +Subproject commit 07101fbc34506defad5b77d8ee66da956b5dac18 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index c3a31f65..07101fbc 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit c3a31f6510659362e28607e85d4e8047c403c14c +Subproject commit 07101fbc34506defad5b77d8ee66da956b5dac18 From 9f07673d856fe53e87f63d74f122f7d033cd368e Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 29 Sep 2025 15:27:51 +0200 Subject: [PATCH 44/46] Updated compile SDK. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 5b375434..3325b9ac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -39,7 +39,7 @@ protobuf { android { namespace 'com.futo.platformplayer' - compileSdk 34 + compileSdk 36 flavorDimensions "buildType" productFlavors { stable { From 547fe7bc131d3c73f2726a26997162487d76f162 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 29 Sep 2025 15:46:45 +0200 Subject: [PATCH 45/46] Updated target SDK. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 3325b9ac..501231f9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,7 +97,7 @@ android { defaultConfig { minSdk 28 - targetSdk 34 + targetSdk 35 versionCode gitVersionCode versionName gitVersionName From 26b547020004888445beeba0d2c4d4d1d9bc3d2d Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 29 Sep 2025 18:45:42 +0200 Subject: [PATCH 46/46] Fix crash fix on async promise handling --- .../com/futo/platformplayer/Extensions_V8.kt | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt index e08cb7e9..bee729a4 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -21,7 +22,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.selects.SelectClause0 import kotlinx.coroutines.selects.SelectClause1 -import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -268,12 +268,25 @@ fun V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred underlyingDef.complete(p0 as T); } override fun onRejected(p0: V8Value?) { - plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..")); + try { + plugin.resolvePromise(promise); + val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented.."); + Logger.i("V8", "Promise rejected, setting exception"); + underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound)); + } + catch(ex: Throwable) { + Logger.e("V8", "Rejection handling failed?" , ex); + } } override fun onCatch(p0: V8Value?) { - plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..")); + try { + plugin.resolvePromise(promise); + val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented.."); + underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound)); + } + catch(ex: Throwable) { + Logger.e("V8", "Catching handling failed?" , ex); + } } }); } @@ -290,10 +303,10 @@ fun V8Value.toException(config: IV8PluginConfig): Throwable { val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" } val msg = p0.getOrDefault(config, "msg", "Promise Exception", null) ?: p0.getOrDefault(config, "message", "Promise Exception", ""); - return Exception("Promise Failed: " + pluginType + msg); + return Throwable("Promise Failed: " + pluginType + msg); } else if(p0 is V8ValueString) - return Exception("Promise Failed:" + p0.value); + return Throwable("Promise Failed:" + p0.value); else return NotImplementedError("onCatch promise not implemented.."); }