fix: prerelease

This commit is contained in:
omkar 2025-01-30 21:58:43 +05:30
parent 00d643a9fd
commit 50fdce5157
276 changed files with 20339 additions and 27348 deletions

View file

@ -7,6 +7,11 @@ An open-source media manager app built with Flutter, designed to stream videos f
- **Cross-Platform Support**: Works on Android, iOS supported by Flutter.
- **Open Source**: Contributions are welcome!
## TODO
- [ ] Update dialog
## Screenshots
<img src="readme/image/home.jpg" width="250" title="Home Page">

View file

@ -56,6 +56,8 @@ dependencies {
implementation('com.google.mlkit:text-recognition-devanagari:16.0.1')
implementation('com.google.mlkit:text-recognition-chinese:16.0.1')
implementation('com.google.mlkit:text-recognition-korean:16.0.1')
implementation("androidx.tv:tv-foundation:1.0.0-alpha12")
implementation("androidx.tv:tv-material:1.1.0-alpha01")
}
flutter {

239
assets/data/regions.json Normal file
View file

@ -0,0 +1,239 @@
{
"AF": "Afghanistan",
"AL": "Albania",
"DZ": "Algeria",
"AS": "American Samoa",
"AD": "Andorra",
"AO": "Angola",
"AI": "Anguilla",
"AQ": "Antarctica",
"AG": "Antigua & Barbuda",
"AR": "Argentina",
"AM": "Armenia",
"AW": "Aruba",
"AU": "Australia",
"AT": "Austria",
"AZ": "Azerbaijan",
"BS": "Bahamas",
"BH": "Bahrain",
"BD": "Bangladesh",
"BB": "Barbados",
"BY": "Belarus",
"BE": "Belgium",
"BZ": "Belize",
"BJ": "Benin",
"BM": "Bermuda",
"BT": "Bhutan",
"BO": "Bolivia",
"BA": "Bosnia & Herzegovina",
"BW": "Botswana",
"BV": "Bouvet Island",
"BR": "Brazil",
"IO": "British Indian Ocean Territory",
"BN": "Brunei Darussalam",
"BG": "Bulgaria",
"BF": "Burkina Faso",
"BI": "Burundi",
"KH": "Cambodia",
"CM": "Cameroon",
"CA": "Canada",
"CV": "Cape Verde",
"KY": "Cayman Islands",
"CF": "Central African Republic",
"TD": "Chad",
"CL": "Chile",
"CN": "China",
"CX": "Christmas Island",
"CC": "Cocos (Keeling) Islands",
"CO": "Colombia",
"KM": "Comoros",
"CG": "Congo",
"CD": "Zaire",
"CK": "Cook Islands",
"CR": "Costa Rica",
"CI": "Cote D'ivoire (Ivory Coast)",
"HR": "Croatia (Hrvatska)",
"CU": "Cuba",
"CY": "Cyprus",
"CZ": "Czech Republic",
"DK": "Denmark",
"DJ": "Djibouti",
"DM": "Dominica",
"DO": "Dominican Republic",
"TP": "East Timor",
"EC": "Ecuador",
"EG": "Egypt",
"SV": "El Salvador",
"GQ": "Equatorial Guinea",
"ER": "Eritrea",
"EE": "Estonia",
"ET": "Ethiopia",
"FK": "Falkland Islands (Malvinas)",
"FO": "Faroe Islands",
"FJ": "Fiji",
"FI": "Finland",
"FR": "France",
"GF": "French Guiana",
"PF": "French Polynesia",
"TF": "French Southern Territories",
"GA": "Gabon",
"GM": "Gambia",
"GE": "Georgia",
"DE": "Germany",
"GH": "Ghana",
"GI": "Gibraltar",
"GB": "United Kingdom",
"GR": "Greece",
"GL": "Greenland",
"GD": "Grenada",
"GP": "Guadeloupe",
"GU": "Guam",
"GT": "Guatemala",
"GN": "Guinea",
"GW": "Guinea-Bissau",
"GY": "Guyana",
"HT": "Haiti",
"HM": "Heard & McDonald Islands",
"VA": "Vatican City (Holy See)",
"HN": "Honduras",
"HK": "Hong Kong",
"HU": "Hungary",
"IS": "Iceland",
"IN": "India",
"ID": "Indonesia",
"IR": "Iran",
"IQ": "Iraq",
"IE": "Ireland",
"IL": "Israel",
"IT": "Italy",
"JM": "Jamaica",
"JP": "Japan",
"JO": "Jordan",
"KZ": "Kazakhstan",
"KE": "Kenya",
"KI": "Kiribati",
"KP": "Korea (North)",
"KR": "Korea (South)",
"KW": "Kuwait",
"KG": "Kyrgyzstan",
"LA": "Laos",
"LV": "Latvia",
"LB": "Lebanon",
"LS": "Lesotho",
"LR": "Liberia",
"LY": "Libya",
"LI": "Liechtenstein",
"LT": "Lithuania",
"LU": "Luxembourg",
"MO": "Macau",
"MK": "Macedonia",
"MG": "Madagascar",
"MW": "Malawi",
"MY": "Malaysia",
"MV": "Maldives",
"ML": "Mali",
"MT": "Malta",
"MH": "Marshall Islands",
"MQ": "Martinique",
"MR": "Mauritania",
"MU": "Mauritius",
"YT": "Mayotte",
"MX": "Mexico",
"FM": "Micronesia",
"MD": "Moldova",
"MC": "Monaco",
"MN": "Mongolia",
"MS": "Montserrat",
"MA": "Morocco",
"MZ": "Mozambique",
"MM": "Myanmar",
"NA": "Namibia",
"NR": "Nauru",
"NP": "Nepal",
"NL": "Netherlands",
"AN": "Netherlands Antilles",
"NC": "New Caledonia",
"NZ": "New Zealand",
"NI": "Nicaragua",
"NE": "Niger",
"NG": "Nigeria",
"NU": "Niue",
"NF": "Norfolk Island",
"MP": "Northern Mariana Islands",
"NO": "Norway",
"OM": "Oman",
"PK": "Pakistan",
"PW": "Palau",
"PA": "Panama",
"PG": "Papua New Guinea",
"PY": "Paraguay",
"PE": "Peru",
"PH": "Philippines",
"PN": "Pitcairn",
"PL": "Poland",
"PT": "Portugal",
"PR": "Puerto Rico",
"QA": "Qatar",
"RE": "Reunion",
"RO": "Romania",
"RU": "Russian Federation",
"RW": "Rwanda",
"SH": "St. Helena",
"KN": "Saint Kitts & Nevis",
"LC": "Saint Lucia",
"PM": "St. Pierre & Miquelon",
"VC": "St. Vincent & the Grenadines",
"WS": "Samoa",
"SM": "San Marino",
"ST": "Sao Tome & Principe",
"SA": "Saudi Arabia",
"SN": "Senegal",
"SC": "Seychelles",
"SL": "Sierra Leone",
"SG": "Singapore",
"SK": "Slovak Republic",
"SI": "Slovenia",
"SB": "Solomon Islands",
"SO": "Somalia",
"ZA": "South Africa",
"GS": "S.Georgia & S.Sandwich Islands",
"ES": "Spain",
"LK": "Sri Lanka",
"SD": "Sudan",
"SR": "Suriname",
"SJ": "Svalbard & Jan Mayen Islands",
"SZ": "Swaziland",
"SE": "Sweden",
"CH": "Switzerland",
"SY": "Syria",
"TW": "Taiwan",
"TJ": "Tajikistan",
"TZ": "Tanzania",
"TH": "Thailand",
"TG": "Togo",
"TK": "Tokelau",
"TO": "Tonga",
"TT": "Trinidad & Tobago",
"TN": "Tunisia",
"TR": "Turkey",
"TM": "Turkmenistan",
"TC": "Turks & Caicos Islands",
"TV": "Tuvalu",
"UG": "Uganda",
"UA": "Ukraine",
"AE": "United Arab Emirates",
"US": "United States",
"UY": "Uruguay",
"UZ": "Uzbekistan",
"VU": "Vanuatu",
"VE": "Venezuela",
"VN": "Viet Nam",
"VG": "Virgin Islands (British)",
"VI": "Virgin Islands (U.S.)",
"WF": "Wallis & Futuna Islands",
"EH": "Western Sahara",
"YE": "Yemen",
"YU": "Yugoslavia",
"ZM": "Zambia",
"ZW": "Zimbabwe"
}

View file

@ -0,0 +1,937 @@
[
{
"iso_639_1": "xx",
"english_name": "No Language",
"name": "No Language"
},
{
"iso_639_1": "aa",
"english_name": "Afar",
"name": ""
},
{
"iso_639_1": "af",
"english_name": "Afrikaans",
"name": "Afrikaans"
},
{
"iso_639_1": "ak",
"english_name": "Akan",
"name": ""
},
{
"iso_639_1": "an",
"english_name": "Aragonese",
"name": ""
},
{
"iso_639_1": "as",
"english_name": "Assamese",
"name": ""
},
{
"iso_639_1": "av",
"english_name": "Avaric",
"name": ""
},
{
"iso_639_1": "ae",
"english_name": "Avestan",
"name": ""
},
{
"iso_639_1": "ay",
"english_name": "Aymara",
"name": ""
},
{
"iso_639_1": "az",
"english_name": "Azerbaijani",
"name": "Azərbaycan"
},
{
"iso_639_1": "ba",
"english_name": "Bashkir",
"name": ""
},
{
"iso_639_1": "bm",
"english_name": "Bambara",
"name": "Bamanankan"
},
{
"iso_639_1": "bn",
"english_name": "Bengali",
"name": "বাংলা"
},
{
"iso_639_1": "bi",
"english_name": "Bislama",
"name": ""
},
{
"iso_639_1": "bo",
"english_name": "Tibetan",
"name": ""
},
{
"iso_639_1": "bs",
"english_name": "Bosnian",
"name": "Bosanski"
},
{
"iso_639_1": "br",
"english_name": "Breton",
"name": ""
},
{
"iso_639_1": "ca",
"english_name": "Catalan",
"name": "Català"
},
{
"iso_639_1": "cs",
"english_name": "Czech",
"name": "Český"
},
{
"iso_639_1": "ch",
"english_name": "Chamorro",
"name": "Finu' Chamorro"
},
{
"iso_639_1": "ce",
"english_name": "Chechen",
"name": ""
},
{
"iso_639_1": "cu",
"english_name": "Slavic",
"name": ""
},
{
"iso_639_1": "cv",
"english_name": "Chuvash",
"name": ""
},
{
"iso_639_1": "kw",
"english_name": "Cornish",
"name": ""
},
{
"iso_639_1": "co",
"english_name": "Corsican",
"name": ""
},
{
"iso_639_1": "cr",
"english_name": "Cree",
"name": ""
},
{
"iso_639_1": "cy",
"english_name": "Welsh",
"name": "Cymraeg"
},
{
"iso_639_1": "da",
"english_name": "Danish",
"name": "Dansk"
},
{
"iso_639_1": "de",
"english_name": "German",
"name": "Deutsch"
},
{
"iso_639_1": "dv",
"english_name": "Divehi",
"name": ""
},
{
"iso_639_1": "dz",
"english_name": "Dzongkha",
"name": ""
},
{
"iso_639_1": "en",
"english_name": "English",
"name": "English"
},
{
"iso_639_1": "eo",
"english_name": "Esperanto",
"name": "Esperanto"
},
{
"iso_639_1": "et",
"english_name": "Estonian",
"name": "Eesti"
},
{
"iso_639_1": "eu",
"english_name": "Basque",
"name": "euskera"
},
{
"iso_639_1": "fo",
"english_name": "Faroese",
"name": ""
},
{
"iso_639_1": "fj",
"english_name": "Fijian",
"name": ""
},
{
"iso_639_1": "fi",
"english_name": "Finnish",
"name": "suomi"
},
{
"iso_639_1": "fr",
"english_name": "French",
"name": "Français"
},
{
"iso_639_1": "fy",
"english_name": "Frisian",
"name": ""
},
{
"iso_639_1": "ff",
"english_name": "Fulah",
"name": "Fulfulde"
},
{
"iso_639_1": "gd",
"english_name": "Gaelic",
"name": ""
},
{
"iso_639_1": "ga",
"english_name": "Irish",
"name": "Gaeilge"
},
{
"iso_639_1": "gl",
"english_name": "Galician",
"name": "Galego"
},
{
"iso_639_1": "gv",
"english_name": "Manx",
"name": ""
},
{
"iso_639_1": "gn",
"english_name": "Guarani",
"name": ""
},
{
"iso_639_1": "gu",
"english_name": "Gujarati",
"name": ""
},
{
"iso_639_1": "ht",
"english_name": "Haitian; Haitian Creole",
"name": ""
},
{
"iso_639_1": "ha",
"english_name": "Hausa",
"name": "Hausa"
},
{
"iso_639_1": "sh",
"english_name": "Serbo-Croatian",
"name": ""
},
{
"iso_639_1": "hz",
"english_name": "Herero",
"name": ""
},
{
"iso_639_1": "ho",
"english_name": "Hiri Motu",
"name": ""
},
{
"iso_639_1": "hr",
"english_name": "Croatian",
"name": "Hrvatski"
},
{
"iso_639_1": "hu",
"english_name": "Hungarian",
"name": "Magyar"
},
{
"iso_639_1": "ig",
"english_name": "Igbo",
"name": ""
},
{
"iso_639_1": "io",
"english_name": "Ido",
"name": ""
},
{
"iso_639_1": "ii",
"english_name": "Yi",
"name": ""
},
{
"iso_639_1": "iu",
"english_name": "Inuktitut",
"name": ""
},
{
"iso_639_1": "ie",
"english_name": "Interlingue",
"name": ""
},
{
"iso_639_1": "ia",
"english_name": "Interlingua",
"name": ""
},
{
"iso_639_1": "id",
"english_name": "Indonesian",
"name": "Bahasa indonesia"
},
{
"iso_639_1": "ik",
"english_name": "Inupiaq",
"name": ""
},
{
"iso_639_1": "is",
"english_name": "Icelandic",
"name": "Íslenska"
},
{
"iso_639_1": "it",
"english_name": "Italian",
"name": "Italiano"
},
{
"iso_639_1": "jv",
"english_name": "Javanese",
"name": ""
},
{
"iso_639_1": "ja",
"english_name": "Japanese",
"name": "日本語"
},
{
"iso_639_1": "kl",
"english_name": "Kalaallisut",
"name": ""
},
{
"iso_639_1": "kn",
"english_name": "Kannada",
"name": "?????"
},
{
"iso_639_1": "ks",
"english_name": "Kashmiri",
"name": ""
},
{
"iso_639_1": "ka",
"english_name": "Georgian",
"name": "ქართული"
},
{
"iso_639_1": "kr",
"english_name": "Kanuri",
"name": ""
},
{
"iso_639_1": "kk",
"english_name": "Kazakh",
"name": "қазақ"
},
{
"iso_639_1": "km",
"english_name": "Khmer",
"name": ""
},
{
"iso_639_1": "ki",
"english_name": "Kikuyu",
"name": ""
},
{
"iso_639_1": "rw",
"english_name": "Kinyarwanda",
"name": "Kinyarwanda"
},
{
"iso_639_1": "ky",
"english_name": "Kirghiz",
"name": "??????"
},
{
"iso_639_1": "kv",
"english_name": "Komi",
"name": ""
},
{
"iso_639_1": "kg",
"english_name": "Kongo",
"name": ""
},
{
"iso_639_1": "ko",
"english_name": "Korean",
"name": "한국어/조선말"
},
{
"iso_639_1": "kj",
"english_name": "Kuanyama",
"name": ""
},
{
"iso_639_1": "ku",
"english_name": "Kurdish",
"name": ""
},
{
"iso_639_1": "lo",
"english_name": "Lao",
"name": ""
},
{
"iso_639_1": "la",
"english_name": "Latin",
"name": "Latin"
},
{
"iso_639_1": "lv",
"english_name": "Latvian",
"name": "Latviešu"
},
{
"iso_639_1": "li",
"english_name": "Limburgish",
"name": ""
},
{
"iso_639_1": "ln",
"english_name": "Lingala",
"name": ""
},
{
"iso_639_1": "lt",
"english_name": "Lithuanian",
"name": "Lietuvių"
},
{
"iso_639_1": "lb",
"english_name": "Letzeburgesch",
"name": ""
},
{
"iso_639_1": "lu",
"english_name": "Luba-Katanga",
"name": ""
},
{
"iso_639_1": "lg",
"english_name": "Ganda",
"name": ""
},
{
"iso_639_1": "mh",
"english_name": "Marshall",
"name": ""
},
{
"iso_639_1": "ml",
"english_name": "Malayalam",
"name": ""
},
{
"iso_639_1": "mr",
"english_name": "Marathi",
"name": ""
},
{
"iso_639_1": "mg",
"english_name": "Malagasy",
"name": ""
},
{
"iso_639_1": "mt",
"english_name": "Maltese",
"name": "Malti"
},
{
"iso_639_1": "mo",
"english_name": "Moldavian",
"name": ""
},
{
"iso_639_1": "mn",
"english_name": "Mongolian",
"name": ""
},
{
"iso_639_1": "mi",
"english_name": "Maori",
"name": ""
},
{
"iso_639_1": "ms",
"english_name": "Malay",
"name": "Bahasa melayu"
},
{
"iso_639_1": "my",
"english_name": "Burmese",
"name": ""
},
{
"iso_639_1": "na",
"english_name": "Nauru",
"name": ""
},
{
"iso_639_1": "nv",
"english_name": "Navajo",
"name": ""
},
{
"iso_639_1": "nr",
"english_name": "Ndebele",
"name": ""
},
{
"iso_639_1": "nd",
"english_name": "Ndebele",
"name": ""
},
{
"iso_639_1": "ng",
"english_name": "Ndonga",
"name": ""
},
{
"iso_639_1": "ne",
"english_name": "Nepali",
"name": ""
},
{
"iso_639_1": "nl",
"english_name": "Dutch",
"name": "Nederlands"
},
{
"iso_639_1": "nn",
"english_name": "Norwegian Nynorsk",
"name": ""
},
{
"iso_639_1": "nb",
"english_name": "Norwegian Bokmål",
"name": "Bokmål"
},
{
"iso_639_1": "no",
"english_name": "Norwegian",
"name": "Norsk"
},
{
"iso_639_1": "ny",
"english_name": "Chichewa; Nyanja",
"name": ""
},
{
"iso_639_1": "oc",
"english_name": "Occitan",
"name": ""
},
{
"iso_639_1": "oj",
"english_name": "Ojibwa",
"name": ""
},
{
"iso_639_1": "or",
"english_name": "Oriya",
"name": ""
},
{
"iso_639_1": "om",
"english_name": "Oromo",
"name": ""
},
{
"iso_639_1": "os",
"english_name": "Ossetian; Ossetic",
"name": ""
},
{
"iso_639_1": "pa",
"english_name": "Punjabi",
"name": "ਪੰਜਾਬੀ"
},
{
"iso_639_1": "pi",
"english_name": "Pali",
"name": ""
},
{
"iso_639_1": "pl",
"english_name": "Polish",
"name": "Polski"
},
{
"iso_639_1": "pt",
"english_name": "Portuguese",
"name": "Português"
},
{
"iso_639_1": "qu",
"english_name": "Quechua",
"name": ""
},
{
"iso_639_1": "rm",
"english_name": "Raeto-Romance",
"name": ""
},
{
"iso_639_1": "ro",
"english_name": "Romanian",
"name": "Română"
},
{
"iso_639_1": "rn",
"english_name": "Rundi",
"name": "Kirundi"
},
{
"iso_639_1": "ru",
"english_name": "Russian",
"name": "Pусский"
},
{
"iso_639_1": "sg",
"english_name": "Sango",
"name": ""
},
{
"iso_639_1": "sa",
"english_name": "Sanskrit",
"name": ""
},
{
"iso_639_1": "si",
"english_name": "Sinhalese",
"name": "සිංහල"
},
{
"iso_639_1": "sk",
"english_name": "Slovak",
"name": "Slovenčina"
},
{
"iso_639_1": "sl",
"english_name": "Slovenian",
"name": "Slovenščina"
},
{
"iso_639_1": "se",
"english_name": "Northern Sami",
"name": ""
},
{
"iso_639_1": "sm",
"english_name": "Samoan",
"name": ""
},
{
"iso_639_1": "sn",
"english_name": "Shona",
"name": ""
},
{
"iso_639_1": "sd",
"english_name": "Sindhi",
"name": ""
},
{
"iso_639_1": "so",
"english_name": "Somali",
"name": "Somali"
},
{
"iso_639_1": "st",
"english_name": "Sotho",
"name": ""
},
{
"iso_639_1": "es",
"english_name": "Spanish",
"name": "Español"
},
{
"iso_639_1": "sq",
"english_name": "Albanian",
"name": "shqip"
},
{
"iso_639_1": "sc",
"english_name": "Sardinian",
"name": ""
},
{
"iso_639_1": "sr",
"english_name": "Serbian",
"name": "Srpski"
},
{
"iso_639_1": "ss",
"english_name": "Swati",
"name": ""
},
{
"iso_639_1": "su",
"english_name": "Sundanese",
"name": ""
},
{
"iso_639_1": "sw",
"english_name": "Swahili",
"name": "Kiswahili"
},
{
"iso_639_1": "sv",
"english_name": "Swedish",
"name": "svenska"
},
{
"iso_639_1": "ty",
"english_name": "Tahitian",
"name": ""
},
{
"iso_639_1": "ta",
"english_name": "Tamil",
"name": "தமிழ்"
},
{
"iso_639_1": "tt",
"english_name": "Tatar",
"name": ""
},
{
"iso_639_1": "te",
"english_name": "Telugu",
"name": "తెలుగు"
},
{
"iso_639_1": "tg",
"english_name": "Tajik",
"name": ""
},
{
"iso_639_1": "tl",
"english_name": "Tagalog",
"name": ""
},
{
"iso_639_1": "th",
"english_name": "Thai",
"name": "ภาษาไทย"
},
{
"iso_639_1": "ti",
"english_name": "Tigrinya",
"name": ""
},
{
"iso_639_1": "to",
"english_name": "Tonga",
"name": ""
},
{
"iso_639_1": "tn",
"english_name": "Tswana",
"name": ""
},
{
"iso_639_1": "ts",
"english_name": "Tsonga",
"name": ""
},
{
"iso_639_1": "tk",
"english_name": "Turkmen",
"name": ""
},
{
"iso_639_1": "tr",
"english_name": "Turkish",
"name": "Türkçe"
},
{
"iso_639_1": "tw",
"english_name": "Twi",
"name": ""
},
{
"iso_639_1": "ug",
"english_name": "Uighur",
"name": ""
},
{
"iso_639_1": "uk",
"english_name": "Ukrainian",
"name": "Український"
},
{
"iso_639_1": "ur",
"english_name": "Urdu",
"name": "اردو"
},
{
"iso_639_1": "uz",
"english_name": "Uzbek",
"name": "ozbek"
},
{
"iso_639_1": "ve",
"english_name": "Venda",
"name": ""
},
{
"iso_639_1": "vi",
"english_name": "Vietnamese",
"name": "Tiếng Việt"
},
{
"iso_639_1": "vo",
"english_name": "Volapük",
"name": ""
},
{
"iso_639_1": "wa",
"english_name": "Walloon",
"name": ""
},
{
"iso_639_1": "wo",
"english_name": "Wolof",
"name": "Wolof"
},
{
"iso_639_1": "xh",
"english_name": "Xhosa",
"name": ""
},
{
"iso_639_1": "yi",
"english_name": "Yiddish",
"name": ""
},
{
"iso_639_1": "za",
"english_name": "Zhuang",
"name": ""
},
{
"iso_639_1": "zu",
"english_name": "Zulu",
"name": "isiZulu"
},
{
"iso_639_1": "ab",
"english_name": "Abkhazian",
"name": ""
},
{
"iso_639_1": "zh",
"english_name": "Mandarin",
"name": "普通话"
},
{
"iso_639_1": "ps",
"english_name": "Pushto",
"name": "پښتو"
},
{
"iso_639_1": "am",
"english_name": "Amharic",
"name": ""
},
{
"iso_639_1": "ar",
"english_name": "Arabic",
"name": "العربية"
},
{
"iso_639_1": "be",
"english_name": "Belarusian",
"name": "беларуская мова"
},
{
"iso_639_1": "bg",
"english_name": "Bulgarian",
"name": "български език"
},
{
"iso_639_1": "cn",
"english_name": "Cantonese",
"name": "广州话 / 廣州話"
},
{
"iso_639_1": "mk",
"english_name": "Macedonian",
"name": ""
},
{
"iso_639_1": "ee",
"english_name": "Ewe",
"name": ʋegbe"
},
{
"iso_639_1": "el",
"english_name": "Greek",
"name": "ελληνικά"
},
{
"iso_639_1": "fa",
"english_name": "Persian",
"name": "فارسی"
},
{
"iso_639_1": "he",
"english_name": "Hebrew",
"name": "עִבְרִית"
},
{
"iso_639_1": "hi",
"english_name": "Hindi",
"name": "हिन्दी"
},
{
"iso_639_1": "hy",
"english_name": "Armenian",
"name": ""
},
{
"iso_639_1": "yo",
"english_name": "Yoruba",
"name": "Èdè Yorùbá"
}
]

View file

@ -7,8 +7,6 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
@ -135,9 +133,6 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- pdfrx (0.0.3):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
@ -181,7 +176,6 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
@ -195,7 +189,6 @@ DEPENDENCIES:
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pdfrx (from `.symlinks/plugins/pdfrx/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@ -235,8 +228,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
@ -255,8 +246,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pdfrx:
:path: ".symlinks/plugins/pdfrx/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
@ -276,7 +265,6 @@ SPEC CHECKSUMS:
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@ -303,7 +291,6 @@ SPEC CHECKSUMS:
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
pdfrx: 07fc287c47ea8d027c4ed56457f8a1aa74d73594
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8

55
lib/app/app.dart Normal file
View file

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart';
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
import 'package:provider/provider.dart';
import '../features/settings/service/selected_profile.dart';
import '../features/theme/provider/theme_provider.dart';
import 'app_router.dart';
class AppDefault extends StatefulWidget {
const AppDefault({
super.key,
});
@override
State<AppDefault> createState() => _AppDefaultState();
}
class _AppDefaultState extends State<AppDefault> {
late GoRouter _router;
@override
void initState() {
_router = createRouterDesktop();
if (AppPocketBaseService.instance.pb.authStore.isValid) {
SelectedProfileService.instance.initialize();
}
super.initState();
}
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
final theme = themeProvider.getTheme();
return ChangeNotifierProvider(
create: (context) => VideoSettingsProvider(),
child: MaterialApp.router(
routerConfig: _router,
title: "Madari",
theme: theme.copyWith(
textTheme: GoogleFonts.exo2TextTheme(theme.textTheme),
),
debugShowCheckedModeBanner: false, // comes in the way of the search
),
);
},
);
}
}

239
lib/app/app_router.dart Normal file
View file

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:madari_client/features/downloads/pages/downloads_page.dart';
import 'package:madari_client/features/offline_ratings/pages/offline_ratings.dart';
import 'package:madari_client/features/settings/pages/profile_page.dart';
import '../features/accounts/pages/external_account.dart';
import '../features/auth/pages/forget_password_page.dart';
import '../features/auth/pages/signin_page.dart';
import '../features/auth/pages/signup_page.dart';
import '../features/explore/pages/explore.page.dart';
import '../features/home/pages/home_page.dart';
import '../features/layout/widgets/scaffold_with_nav.dart';
import '../features/library/container/create_list_widget.dart';
import '../features/library/pages/library.page.dart';
import '../features/library/pages/list_detail_page.dart';
import '../features/library/types/library_types.dart';
import '../features/pocketbase/service/pocketbase.service.dart';
import '../features/settings/pages/appearance_page.dart';
import '../features/settings/pages/change_password_page.dart';
import '../features/settings/pages/debug/logs_page.dart';
import '../features/settings/pages/full_profile_selector.dart';
import '../features/settings/pages/layout_page.dart';
import '../features/settings/pages/playback_settings_page.dart';
import '../features/settings/pages/settings_page.dart';
import '../features/settings/pages/subprofiles_page.dart';
import '../features/streamio_addons/pages/stremio_addons_page.dart';
import '../features/video_player/container/video_player.dart';
import '../features/widgetter/plugins/stremio/pages/streamio_item_viewer.dart';
import '../features/zeku/pages/integration_page.dart';
final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<StatefulNavigationShellState> _shellNavigatorKey =
GlobalKey<StatefulNavigationShellState>();
final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _searchNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _downloadsNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _settingsNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _exploreNavigatorKey =
GlobalKey<NavigatorState>();
GoRouter createRouterDesktop() {
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/',
refreshListenable: ValueNotifier(
AppPocketBaseService.instance.pb.authStore.onChange,
),
redirect: (context, state) {
final isLoggedIn = AppPocketBaseService.instance.pb.authStore.isValid;
final isAuthRoute = state.uri.path == '/signin' ||
state.uri.path == '/signup' ||
state.uri.path == '/forgot-password';
if (!isLoggedIn && !isAuthRoute) {
return '/signin';
}
if (isLoggedIn && isAuthRoute) {
return '/';
}
return null;
},
routes: [
GoRoute(
path: '/signin',
builder: (context, state) => const SignInPage(),
),
GoRoute(
path: '/forgot-password',
builder: (context, state) => const ForgotPasswordPage(),
),
GoRoute(
path: '/signup',
builder: (context, state) => const SignUpPage(),
),
GoRoute(
path: "/downloads",
builder: (context, state) => const DownloadsPage(),
),
GoRoute(
path: "/settings/integration",
builder: (context, state) => const IntegrationPage(),
),
StatefulShellRoute.indexedStack(
key: _shellNavigatorKey,
builder: (context, state, navigationShell) {
return ScaffoldWithNav(
child: navigationShell,
);
},
branches: [
StatefulShellBranch(
navigatorKey: _homeNavigatorKey,
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
],
),
StatefulShellBranch(
navigatorKey: _searchNavigatorKey,
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const HomePage(
hasSearch: true,
),
),
],
),
StatefulShellBranch(
navigatorKey: _exploreNavigatorKey,
routes: [
GoRoute(
path: '/explore',
builder: (context, state) => const ExplorePage(),
),
],
),
StatefulShellBranch(
navigatorKey: _downloadsNavigatorKey,
routes: [
GoRoute(
path: '/library',
builder: (context, state) => const LibraryPage(),
),
],
),
StatefulShellBranch(
navigatorKey: _settingsNavigatorKey,
routes: settingsRoutes,
)
],
),
GoRoute(
path: "/layout",
builder: (context, state) => const LayoutPage(),
),
GoRoute(
path: '/library/create',
builder: (context, state) => const CreateListPage(),
),
GoRoute(
path: '/library/:id',
builder: (context, state) {
final list = state.extra as ListModel;
return ListDetailsPage(list: list);
},
),
GoRoute(
path: "/profile",
builder: (context, state) {
return const FullProfileSelectorPage();
},
),
GoRoute(
path: "/profile/manage",
builder: (context, state) => const SubprofilesPage(),
),
GoRoute(
path: '/meta/:type/:id',
builder: (context, state) {
return StreamioItemViewer(
id: state.pathParameters['id']!,
type: state.pathParameters['type']!,
image: state.uri.queryParameters["image"],
name: state.uri.queryParameters['name'],
prefix: state.uri.queryParameters['prefix'],
meta: state.extra is Map ? (state.extra as Map)["meta"] : null,
);
},
),
GoRoute(
path: '/player/:type/:id/:stream',
builder: (context, state) => VideoPlayer(
id: state.pathParameters['id']!,
type: state.pathParameters['type']!,
stream: state.pathParameters["stream"]!,
selectedIndex: state.uri.queryParameters["index"],
meta: state.extra is Map ? (state.extra as Map)["meta"] : null,
),
),
GoRoute(
path: "/settings/offline-ratings",
builder: (context, state) => const OfflineRatings(),
),
GoRoute(
path: '/settings/addons',
builder: (context, state) => const StremioAddonsPage(),
),
],
);
}
final List<RouteBase> settingsRoutes = [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/settings/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: '/settings/appearance',
builder: (context, state) => const AppearancePage(),
),
GoRoute(
path: '/settings/stremio',
builder: (context, state) => const StremioAddonsPage(),
),
GoRoute(
path: '/settings/playback',
builder: (context, state) => const PlaybackSettingsPage(),
),
GoRoute(
path: '/settings/external-account',
builder: (context, state) => const ExternalAccount(),
),
GoRoute(
path: '/settings/debug',
builder: (context, state) => const LogsPage(),
),
GoRoute(
path: "/settings/security",
builder: (context, state) => const ChangePasswordPage(),
),
GoRoute(
path: "/settings/subprofiles",
builder: (context, state) => const SubprofilesPage(),
),
];

View file

@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
class SeasonSelector extends StatelessWidget {
const SeasonSelector({
class AppWeb extends StatelessWidget {
const AppWeb({
super.key,
});
@override
Widget build(BuildContext context) {
return Container();
return const MaterialApp();
}
}

19
lib/consts/data.dart Normal file
View file

@ -0,0 +1,19 @@
const List<DefaultAddon> defaultAppAddons = [
DefaultAddon(
icon: "https://downloads.madari.media/icon.png",
title: "Madari Catalog",
url: "https://catalog.madari.media/manifest.json",
),
];
class DefaultAddon {
final String title;
final String icon;
final String url;
const DefaultAddon({
required this.title,
required this.url,
required this.icon,
});
}

55
lib/data/db.dart Normal file
View file

@ -0,0 +1,55 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'tables/ratings.dart';
part 'db.g.dart';
@DriftDatabase(tables: [RatingTable])
class AppDatabase extends _$AppDatabase {
AppDatabase._() : super(_openConnection());
static AppDatabase? _instance;
factory AppDatabase() {
_instance ??= AppDatabase._();
return _instance!;
}
Future<double?> getRatingByTConst(String tconst) async {
try {
final query = select(ratingTable)
..where((tbl) => tbl.tconst.equals(tconst));
final result = await query.getSingleOrNull();
return result?.averageRating;
} catch (e) {
print('Error fetching IMDb rating: $e');
return null;
}
}
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'app_db',
);
}
@override
Future<void> close() async {
try {
await super.close();
_instance = null;
} catch (e) {
throw Exception('Failed to close database connection: $e');
}
}
static void clearInstance() {
_instance?.close();
_instance = null;
}
}

View file

@ -0,0 +1,10 @@
import 'package:drift/drift.dart';
class RatingTable extends Table {
TextColumn get tconst => text()();
RealColumn get averageRating => real()();
IntColumn get numVotes => integer()();
@override
Set<Column> get primaryKey => {tconst};
}

View file

@ -1,30 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:madari_client/database/quries/watch_history_queries.dart';
import 'package:madari_client/database/tables/watch_history_table.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [
WatchHistoryTable,
], queries: {}, daos: [
WatchHistoryQueries,
])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'madari_db',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse(
'assets/assets/ignore_this_error_drift_worker.dart.js',
),
),
);
}
}

View file

@ -1,13 +0,0 @@
import 'package:madari_client/database/app_database.dart';
class DatabaseProvider {
late final AppDatabase database;
DatabaseProvider() {
database = AppDatabase();
}
Future<void> close() async {
await database.close();
}
}

View file

@ -1,42 +0,0 @@
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/watch_history_table.dart';
part 'watch_history_queries.g.dart';
@DriftAccessor(tables: [WatchHistoryTable])
class WatchHistoryQueries extends DatabaseAccessor<AppDatabase>
with _$WatchHistoryQueriesMixin {
WatchHistoryQueries(super.db);
Future<List<WatchHistoryTableData>> getWatchHistoryByIds(List<String> ids) {
return (select(watchHistoryTable)..where((t) => t.id.isIn(ids))).get();
}
Future<List<WatchHistoryTableData>> getUnsyncedRecords() {
return (select(watchHistoryTable)
..where((t) =>
t.lastSyncedAt.isNull() |
t.updatedAt.isBiggerThan(t.lastSyncedAt)))
.get();
}
Future<void> insertOrUpdateWatchHistory(WatchHistoryTableCompanion entry) {
return into(watchHistoryTable).insertOnConflictUpdate(entry);
}
Future<void> updateSyncStatus(String id, DateTime syncTime) {
return (update(watchHistoryTable)..where((t) => t.id.equals(id)))
.write(WatchHistoryTableCompanion(lastSyncedAt: Value(syncTime)));
}
Future<WatchHistoryTableData?> getWatchHistoryById(String id) {
return (select(watchHistoryTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
}
Future<void> clearWatchHistory() async {
await delete(watchHistoryTable).go();
}
}

View file

@ -1,15 +0,0 @@
import 'package:drift/drift.dart';
class WatchHistoryTable extends Table {
TextColumn get id => text()();
TextColumn get originalId => text()();
TextColumn get season => text().nullable()();
TextColumn get episode => text().nullable()();
IntColumn get progress => integer().withDefault(const Constant(0))();
RealColumn get duration => real().withDefault(const Constant(0))();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get lastSyncedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}

View file

@ -1,34 +0,0 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:madari_client/features/settings/types/connection.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'engine.dart';
part 'connection.g.dart';
@riverpod
Future<List<Connection>> getConnections(Ref ref) async {
final List<Connection> returnValue = [];
final result = await AppEngine.engine.pb
.collection("connection")
.getFullList(expand: "type");
for (final item in result) {
if (item.id == "telegram") {
continue;
}
returnValue.add(
Connection(
id: item.id,
title: item.getStringValue("title"),
type: item.getStringValue("expand.type.type"),
config: jsonEncode(item.get("config")),
),
);
}
return returnValue;
}

View file

@ -1,67 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'connection_type.g.dart';
@riverpod
Future<ResultList<ConnectionTypeRecord>> connectionTypeList(Ref ref,
{int page = 1}) async {
final result = await AppEngine.engine.pb
.collection("connection_type")
.getList(page: page, sort: "order");
return ResultList(
items: result.items
.map(
(item) => ConnectionTypeRecord.fromRecord(item),
)
.toList(),
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
);
}
class ConnectionTypeRecord extends Jsonable {
final String title;
final String icon;
final String type;
final String id;
ConnectionTypeRecord({
required this.title,
required this.icon,
required this.type,
required this.id,
});
factory ConnectionTypeRecord.fromRecord(RecordModel record) =>
ConnectionTypeRecord.fromJson(record.toJson());
factory ConnectionTypeRecord.fromJson(Map<String, dynamic> json) =>
_$ConnectionTypeRecordFromJson(json);
@override
Map<String, dynamic> toJson() => _$ConnectionTypeRecordToJson(this);
}
ConnectionTypeRecord _$ConnectionTypeRecordFromJson(
Map<String, dynamic> json) =>
ConnectionTypeRecord(
id: json['id'] as String,
title: json['title'] as String,
icon: json['icon'] as String,
type: json['type'] as String,
);
Map<String, dynamic> _$ConnectionTypeRecordToJson(
ConnectionTypeRecord instance) =>
<String, dynamic>{
'title': instance.title,
'icon': instance.icon,
'type': instance.type,
'id': instance.id,
};

View file

@ -1,48 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http_client;
import 'package:pocketbase/pocketbase.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../database/app_database.dart';
import '../database/database_provider.dart';
class AppEngine {
static late AppEngine _instance;
late final DatabaseProvider _databaseProvider;
static Future<void> ensureInitialized() async {
final prefs = await SharedPreferences.getInstance();
final store = AsyncAuthStore(
save: (String data) async => prefs.setString('pb_auth', data),
initial: prefs.getString('pb_auth'),
clear: prefs.clear,
);
_instance = AppEngine(store);
}
static AppEngine get engine => _instance;
late final PocketBase pb;
late final http_client.Client http;
AppDatabase get database => _databaseProvider.database;
Future<void> dispose() async {
await _databaseProvider.close();
}
AppEngine(AuthStore authStore) {
pb = PocketBase(
(kDebugMode ? 'http://100.64.0.1:8090' : 'https://api.madari.media'),
authStore: authStore,
);
_databaseProvider = DatabaseProvider();
http = pb.httpClientFactory();
}
Future<RecordAuth> signIn(String username, String password) {
return pb.collection('users').authWithPassword(username, password);
}
}

View file

@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../features/connections/types/base/base.dart';
import '../features/watch_history/service/zeee_watch_history.dart';
export '../features/connections/types/base/base.dart';
part 'library.g.dart';
final Map<String, ResultList<LibraryRecord>> _libraryListCache = {};
@riverpod
Future<ResultList<LibraryRecord>> libraryList(Ref ref, int page) async {
if (_libraryListCache.containsKey(page.toString())) {
return _libraryListCache[page.toString()]!;
}
final result = await AppEngine.engine.pb.collection("library").getList(
page: page,
sort: "+order",
);
final returnValue = ResultList<LibraryRecord>();
returnValue.totalItems = result.totalItems;
returnValue.perPage = result.perPage;
returnValue.page = result.page;
returnValue.totalPages = result.totalPages;
returnValue.items = result.items.where((item) {
final connectionType = item.getStringValue("connectionType");
if (connectionType != "telegram") {
return true;
}
return false;
}).map((item) {
final i = item;
return LibraryRecord.fromJson({
"id": i.id,
"connectionType": i.getStringValue("connectionType"),
"icon": i.getStringValue("icon"),
"title": i.getStringValue("title"),
"types": i.getListValue<String>("types"),
"config": i.getStringValue("config"),
"connection": i.getStringValue("connection"),
});
}).toList();
_libraryListCache[page.toString()] = returnValue;
return returnValue;
}
final Map<String, ResultList<LibraryItemList>> _cache = {};
@riverpod
Future<ResultList<LibraryItemList>> libraryItemList(
Ref ref,
LibraryRecord library,
List<LibraryItemList>? item,
int page,
String? search,
) async {
final cache = "${library.id}_${page}_$search";
final history = ZeeeWatchHistoryStatic.service;
final result = _cache[cache]!.items.map((item) {
return WatchHistoryGetRequest(
id: item.id,
);
}).toList();
final watchHistory = await history!.getItemWatchHistory(ids: result);
_cache[cache]!.items = _cache[cache]!.items.map((item) {
final history = watchHistory.where((history) => history.id == item.id);
item.history = history.isEmpty ? null : history.first;
return item;
}).toList();
return _cache[cache]!;
}
@JsonSerializable()
class LibraryItemList extends Jsonable {
final String title;
final String? logo;
final int? size;
final String? extra;
final dynamic id;
final String? config;
final DateTime? date;
final double? popularity;
WatchHistory? history;
LibraryItemList({
required this.id,
required this.title,
this.config,
this.logo,
this.size,
this.date,
this.extra,
this.popularity = 0,
this.history,
});
factory LibraryItemList.fromRecord(RecordModel record) =>
LibraryItemList.fromJson(record.toJson());
factory LibraryItemList.fromJson(Map<String, dynamic> json) =>
_$LibraryItemListFromJson(json);
@override
Map<String, dynamic> toJson() => _$LibraryItemListToJson(this);
}
class FolderItem {
final String title;
final String id;
final Widget? icon;
final String? config;
FolderItem({
required this.title,
required this.id,
this.icon,
this.config,
});
}

View file

@ -1,27 +0,0 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:path_provider/path_provider.dart';
extension UIImageToInputImage on ui.Image {
Future<File> toFile({String? fileName}) async {
// Convert image to byte data
final byteData = await toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
throw Exception('Failed to convert ui.Image to ByteData');
}
// Get the application temporary directory
final directory = await getTemporaryDirectory();
// Create a file with a unique name if not provided
final file = File(
'${directory.path}/${fileName ?? 'image_${DateTime.now().millisecondsSinceEpoch}.png'}');
// Write bytes to file
await file.writeAsBytes(byteData.buffer.asUint8List());
return file;
}
}

View file

@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../common/utils/refresh_auth.dart';
import '../../pocketbase/service/pocketbase.service.dart';
class TraktContainer extends StatefulWidget {
const TraktContainer({super.key});
@override
State<TraktContainer> createState() => _TraktContainerState();
}
class _TraktContainerState extends State<TraktContainer> {
final pb = AppPocketBaseService.instance.pb;
bool isLoggedIn = false;
bool isLoading = false;
@override
void initState() {
super.initState();
checkIsLoggedIn();
}
void checkIsLoggedIn() {
final traktToken = pb.authStore.record!.getStringValue("trakt_token");
setState(() {
isLoggedIn = traktToken != "";
});
}
Future<void> removeAccount() async {
setState(() => isLoading = true);
try {
final record = pb.authStore.record!;
record.set("trakt_token", "");
await pb.collection('users').update(
record.id,
body: record.toJson(),
);
await refreshAuth();
setState(() {
isLoggedIn = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) setState(() => isLoading = false);
}
}
Future<void> loginWithTrakt() async {
setState(() => isLoading = true);
try {
await pb.collection("users").authWithOAuth2(
"oidc",
(url) async {
final newUrl = Uri.parse(
url.toString().replaceFirst(
"scope=openid&",
"",
),
);
await launchUrl(newUrl);
},
scopes: ["openid"],
);
await refreshAuth();
checkIsLoggedIn();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) setState(() => isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final isDesktopOrTV = screenWidth > 1024;
final isTablet = screenWidth > 600 && screenWidth <= 1024;
final horizontalPadding = isDesktopOrTV
? screenWidth * 0.2
: isTablet
? 48.0
: 24.0;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: 24,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isLoggedIn ? 'Connected to Trakt' : 'Connect with Trakt',
style: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
isLoggedIn
? 'Your Trakt account is connected'
: 'Sign in to track your movies and shows',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
SizedBox(
height: 50,
child: FilledButton(
onPressed: isLoading
? null
: (isLoggedIn ? removeAccount : loginWithTrakt),
style: FilledButton.styleFrom(
backgroundColor:
isLoggedIn ? colorScheme.error : colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
).copyWith(
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return colorScheme.onPrimary.withValues(alpha: 0.08);
}
if (states.contains(WidgetState.pressed)) {
return colorScheme.onPrimary.withValues(alpha: 0.12);
}
return null;
}),
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
isLoggedIn
? 'Disconnect Account'
: 'Connect with Trakt',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/accounts/container/trakt.container.dart';
import '../../settings/widget/setting_wrapper.dart';
class ExternalAccount extends StatelessWidget {
const ExternalAccount({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("External Accounts"),
),
body: SettingWrapper(
child: ListView(
children: [
_buildSection(
"Trakt",
[
const TraktContainer(),
],
),
],
),
),
);
}
Widget _buildSection(String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...children,
],
),
),
);
}
}

View file

@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../pocketbase/service/pocketbase.service.dart';
class ForgotPasswordPage extends StatefulWidget {
const ForgotPasswordPage({super.key});
@override
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _emailFocusNode = FocusNode();
bool _isLoading = false;
final pocketbase = AppPocketBaseService.instance.pb;
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
focusNode: _emailFocusNode,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.email],
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your email';
}
if (!RegExp(r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value!)) {
return 'Please enter a valid email';
}
return null;
},
onFieldSubmitted: (_) => _requestPasswordReset(),
);
}
Future<void> _requestPasswordReset() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await pocketbase
.collection('users')
.requestPasswordReset(_emailController.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Password reset instructions have been sent to your email'),
backgroundColor: Colors.green,
),
);
// Navigate back to sign in page
context.go('/signin');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send reset instructions: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => context.go('/signin'),
),
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Hero(
tag: 'app_logo',
child: Image.asset(
'assets/icon/icon_mini.png',
height: 80,
width: 80,
),
),
const SizedBox(height: 24),
Text(
'Forgot Password',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enter your email address to receive password reset instructions',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
_buildEmailField(),
const SizedBox(height: 24),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _requestPasswordReset,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Text(
'Reset Password',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
),
);
}
@override
void dispose() {
_emailController.dispose();
_emailFocusNode.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../common/utils/error_handler.dart';
import '../../pocketbase/service/pocketbase.service.dart';
import '../../theme/theme/app_theme.dart';
class SignInPage extends StatefulWidget {
const SignInPage({super.key});
@override
State<SignInPage> createState() => _SignInPageState();
}
class _SignInPageState extends State<SignInPage>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _emailFocusNode = FocusNode();
final _passwordFocusNode = FocusNode();
final _signInButtonFocusNode = FocusNode();
final _forgotPasswordFocusNode = FocusNode();
final _signUpButtonFocusNode = FocusNode();
final _themeToggleFocusNode = FocusNode();
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isLoading = false;
bool _obscurePassword = true;
final pocketbase = AppPocketBaseService.instance.pb;
@override
void initState() {
super.initState();
_setupAnimations();
_animationController.forward();
// Set initial focus to email field
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(_emailFocusNode);
});
}
void _setupAnimations() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
}
Widget _buildAnimatedTextField({
required TextEditingController controller,
required FocusNode focusNode,
required String label,
required IconData icon,
required String? Function(String?) validator,
bool isPassword = false,
required TextInputType keyboardType,
required TextInputAction textInputAction,
required List<String> autofillHints,
required VoidCallback? onEditingComplete,
}) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(
opacity: value,
child: child,
),
);
},
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (focusNode == _emailFocusNode) {
FocusScope.of(context).requestFocus(_passwordFocusNode);
} else if (focusNode == _passwordFocusNode) {
FocusScope.of(context).requestFocus(_forgotPasswordFocusNode);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (focusNode == _passwordFocusNode) {
FocusScope.of(context).requestFocus(_emailFocusNode);
} else if (focusNode == _forgotPasswordFocusNode) {
FocusScope.of(context).requestFocus(_passwordFocusNode);
}
}
}
},
child: TextFormField(
controller: controller,
focusNode: focusNode,
obscureText: isPassword ? _obscurePassword : false,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofillHints: autofillHints,
onEditingComplete: onEditingComplete,
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
suffixIcon: isPassword
? IconButton(
focusNode: FocusNode(),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip:
_obscurePassword ? 'Show password' : 'Hide password',
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
validator: validator,
),
),
);
}
Widget _buildThemeToggle() {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Focus(
focusNode: _themeToggleFocusNode,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _themeToggleFocusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 2,
),
),
child: IconButton(
focusNode: FocusNode(),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return RotationTransition(
turns: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Icon(
isDark ? Icons.light_mode : Icons.dark_mode,
key: ValueKey<bool>(isDark),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
onPressed: () {
AppTheme().toggleTheme();
},
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.select) {
// Handle select button press based on current focus
if (_signInButtonFocusNode.hasFocus) {
_signIn();
}
}
}
},
child: Stack(
children: [
Positioned(
top: 16,
right: 16,
child: _buildThemeToggle(),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Hero(
tag: 'app_logo',
child: Image.asset(
'assets/icon/icon_mini.png',
height: 80,
width: 80,
),
),
const SizedBox(height: 24),
Text(
'Madari',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Welcome back',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAnimatedTextField(
controller: _emailController,
focusNode: _emailFocusNode,
label: 'Email',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onEditingComplete: () {
FocusScope.of(context)
.requestFocus(_passwordFocusNode);
},
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your email';
}
if (!RegExp(
r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value!)) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
_buildAnimatedTextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
label: 'Password',
icon: Icons.lock_outline,
isPassword: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [
AutofillHints.password
],
onEditingComplete: () {
FocusScope.of(context)
.requestFocus(_signInButtonFocusNode);
},
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your password';
}
if (value!.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
Align(
alignment: Alignment.centerRight,
child: Focus(
focusNode: _forgotPasswordFocusNode,
child: TextButton(
onPressed: () {
context.go('/forgot-password');
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
child: const Text('Forgot Password?'),
),
),
),
const SizedBox(height: 24),
Focus(
focusNode: _signInButtonFocusNode,
child: AnimatedContainer(
duration:
const Duration(milliseconds: 300),
height: 50,
decoration: BoxDecoration(
border: Border.all(
color: _signInButtonFocusNode.hasFocus
? colorScheme.primary
: Colors.transparent,
width: 2,
),
borderRadius: BorderRadius.circular(14),
),
child: ElevatedButton(
onPressed: _isLoading ? null : _signIn,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor:
colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<
Color>(
Colors.white,
),
),
)
: const Text(
'Sign In',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Don't have an account? ",
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.7),
),
),
Focus(
focusNode: _signUpButtonFocusNode,
child: TextButton(
onPressed: () {
context.go('/signup');
},
style: TextButton.styleFrom(
foregroundColor:
colorScheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: _signUpButtonFocusNode
.hasFocus
? colorScheme.primary
: Colors.transparent,
width: 2,
),
borderRadius:
BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: const Text(
'Sign Up',
style: TextStyle(
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
],
),
),
],
),
),
),
),
),
),
),
],
),
),
);
}
Future<void> _signIn() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await pocketbase.collection('users').authWithPassword(
_emailController.text.trim(),
_passwordController.text,
);
if (mounted) {
context.go('/profile');
}
} on ClientException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(getErrorMessage(e)),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
_signInButtonFocusNode.dispose();
_forgotPasswordFocusNode.dispose();
_signUpButtonFocusNode.dispose();
_themeToggleFocusNode.dispose();
_animationController.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,538 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/consts/data.dart';
import 'package:madari_client/features/settings/service/selected_profile.dart';
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../common/utils/error_handler.dart';
import '../../pocketbase/service/pocketbase.service.dart';
import '../../theme/theme/app_theme.dart';
import '../service/layout_service.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage>
with SingleTickerProviderStateMixin {
final _logger = Logger("SignUpPage");
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _nameFocusNode = FocusNode();
final _emailFocusNode = FocusNode();
final _passwordFocusNode = FocusNode();
final _confirmPasswordFocusNode = FocusNode();
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
final pocketbase = AppPocketBaseService.instance.pb;
void _setupKeyboardListeners() {
_nameFocusNode.addListener(() {
if (!_nameFocusNode.hasFocus && _nameController.text.isNotEmpty) {
_formKey.currentState?.validate();
}
});
_emailFocusNode.addListener(() {
if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
_formKey.currentState?.validate();
}
});
_passwordFocusNode.addListener(() {
if (!_passwordFocusNode.hasFocus && _passwordController.text.isNotEmpty) {
_formKey.currentState?.validate();
}
});
_confirmPasswordFocusNode.addListener(() {
if (!_confirmPasswordFocusNode.hasFocus &&
_confirmPasswordController.text.isNotEmpty) {
_formKey.currentState?.validate();
}
});
}
@override
void initState() {
super.initState();
_setupAnimations();
_setupKeyboardListeners();
_animationController.forward();
}
void _setupAnimations() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
}
Widget _buildAnimatedTextField({
required TextEditingController controller,
required FocusNode focusNode,
required String label,
required IconData icon,
required String? Function(String?) validator,
bool isPassword = false,
required TextInputType keyboardType,
required TextInputAction textInputAction,
required List<String> autofillHints,
required VoidCallback? onEditingComplete,
bool isConfirmPassword = false,
}) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(
opacity: value,
child: child,
),
);
},
child: TextFormField(
controller: controller,
focusNode: focusNode,
obscureText: isPassword
? (isConfirmPassword ? _obscureConfirmPassword : _obscurePassword)
: false,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofillHints: autofillHints,
onEditingComplete: onEditingComplete,
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
suffixIcon: isPassword
? IconButton(
icon: Icon(
(isConfirmPassword
? _obscureConfirmPassword
: _obscurePassword)
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
if (isConfirmPassword) {
_obscureConfirmPassword = !_obscureConfirmPassword;
} else {
_obscurePassword = !_obscurePassword;
}
});
},
tooltip: (isConfirmPassword
? _obscureConfirmPassword
: _obscurePassword)
? 'Show password'
: 'Hide password',
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
validator: validator,
),
);
}
Widget _buildThemeToggle() {
final isDark = Theme.of(context).brightness == Brightness.dark;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: IconButton(
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return RotationTransition(
turns: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Icon(
isDark ? Icons.light_mode : Icons.dark_mode,
key: ValueKey<bool>(isDark),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
onPressed: () {
AppTheme().toggleTheme();
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: Stack(
children: [
Positioned(
top: 16,
right: 16,
child: _buildThemeToggle(),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Hero(
tag: 'app_logo',
child: Image.asset(
'assets/icon/icon_mini.png',
height: 80,
width: 80,
),
),
const SizedBox(height: 24),
Text(
'Madari',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Create your account',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAnimatedTextField(
controller: _nameController,
focusNode: _nameFocusNode,
label: 'Name',
icon: Icons.person_outline,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.name],
onEditingComplete: () {
_emailFocusNode.requestFocus();
},
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your name';
}
return null;
},
),
const SizedBox(height: 16),
_buildAnimatedTextField(
controller: _emailController,
focusNode: _emailFocusNode,
label: 'Email',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onEditingComplete: () {
_passwordFocusNode.requestFocus();
},
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your email';
}
if (!RegExp(
r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value!)) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
_buildAnimatedTextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
label: 'Password',
icon: Icons.lock_outline,
isPassword: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.next,
autofillHints: const [
AutofillHints.newPassword
],
onEditingComplete: () {
_confirmPasswordFocusNode.requestFocus();
},
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter your password';
}
if (value!.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 16),
_buildAnimatedTextField(
controller: _confirmPasswordController,
focusNode: _confirmPasswordFocusNode,
label: 'Confirm Password',
icon: Icons.lock_outline,
isPassword: true,
isConfirmPassword: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [
AutofillHints.newPassword
],
onEditingComplete: _signUp,
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please confirm your password';
}
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 24),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _signUp,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Text(
'Sign Up',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Already have an account? ',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.7),
),
),
TextButton(
onPressed: () {
context.go('/signin');
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
child: const Text(
'Sign In',
style: TextStyle(
fontWeight: FontWeight.w600),
),
),
],
),
],
),
),
],
),
),
),
),
),
),
),
],
),
);
}
Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final userData = {
"email": _emailController.text.trim(),
"password": _passwordController.text,
"passwordConfirm": _confirmPasswordController.text,
"name": _nameController.text.trim(),
};
final user = await pocketbase.collection('users').create(body: userData);
await pocketbase.collection('users').authWithPassword(
_emailController.text.trim(),
_passwordController.text,
);
final profile = await pocketbase.collection('account_profile').create(
body: {
"name": _nameController.text.trim(),
"can_search": true,
'user': pocketbase.authStore.record!.id,
},
);
await SelectedProfileService.instance.setSelectedProfile(profile.id);
for (final defaultAddon in defaultAppAddons) {
final manifest = await StremioAddonService.instance
.validateManifest(defaultAddon.url, noCache: true)
.queryFn();
await StremioAddonService.instance.saveAddon(manifest);
}
await LayoutService.instance.addAllHomeWidgets();
if (mounted) {
context.go('/profile');
}
} on ClientException catch (e, stack) {
_logger.warning("Unable to sign up", e, stack);
if (mounted) {
String errorMessage = getErrorMessage(e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'OK',
textColor: Theme.of(context).colorScheme.onError,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_nameFocusNode.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
_confirmPasswordFocusNode.dispose();
_animationController.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,56 @@
import 'package:logging/logging.dart';
import '../../pocketbase/service/pocketbase.service.dart';
import '../../widgetter/plugin_base.dart';
import '../../widgetter/service/home_layout_service.dart';
class LayoutService {
static final LayoutService _instance = LayoutService._internal();
static LayoutService get instance => _instance;
final _logger = Logger('LayoutService');
LayoutService._internal();
Future<(int, String?)> addAllHomeWidgets() async {
try {
_logger.info('Adding all widgets for new account');
final userId = AppPocketBaseService.instance.pb.authStore.record!.id;
int successCount = 0;
final result = PluginRegistry.instance.getAvailablePlugins();
final presets = await Future.wait(
result.map((item) => item.presets()),
);
final allWidgets = presets.expand((element) => element).toList();
for (final preset in allWidgets) {
final newWidget = LayoutWidgetConfig.fromPreset(
preset,
userId,
successCount,
);
final success = await HomeLayoutService.instance.saveLayoutWidget(
newWidget,
);
if (success) {
successCount++;
}
}
if (successCount > 0) {
_logger.info('Successfully added $successCount widgets');
return (successCount, null);
} else {
return (0, 'Failed to add widgets. Please try again.');
}
} catch (e) {
_logger.severe('Error adding all widgets', e);
return (0, 'Failed to add widgets. Please check your connection.');
}
}
}

View file

@ -1,155 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/utils/ocr_file.dart';
class ChatAction extends StatefulWidget {
final void Function({
String? actionId,
Map<String, String>? files,
}) onClose;
final String? actionId;
const ChatAction({
super.key,
required this.onClose,
this.actionId,
});
@override
State<ChatAction> createState() => _ChatActionState();
}
class _ChatActionState extends State<ChatAction> {
final List<Map<String, String>> _commandItems = [
{
'id': 'file-upload',
'title': 'Upload File',
'description': 'Share a document or image',
},
];
String? content;
bool _isLoading = true;
@override
void initState() {
super.initState();
AppEngine.engine.pb
.collection("ai_action")
.getList(perPage: 50)
.then((docs) {
if (!mounted) {
return;
}
for (final item in docs.items) {
_commandItems.add({
'id': item.id,
'title': item.getStringValue("title"),
'description': item.getStringValue("description"),
});
}
setState(() {
_isLoading = false;
});
}).catchError((err) {
setState(() {
_isLoading = false;
});
});
}
void attachItem() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: [
"pdf",
"png",
"bpm",
"jpeg",
"jpg",
],
);
if ((result?.count ?? 0) == 0) {
widget.onClose();
return;
}
final images = await ocrFiles(result!.files);
Map<String, String> files = {};
for (final (index, image) in images.indexed) {
files[result.files[index].name] = image;
}
widget.onClose(
files: files,
);
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Material(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.withOpacity(0.2),
),
),
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _commandItems.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _commandItems.length) {
return Container(
padding: const EdgeInsets.only(
bottom: 24,
),
child: const Center(
child: CircularProgressIndicator(),
),
);
}
final item = _commandItems[index];
return ListTile(
selected: item['id'] == widget.actionId,
leading: Icon(
item['id'] == 'file-upload'
? Icons.file_present
: Icons.chat_bubble_outline,
),
title: Text(item['title'] ?? ''),
subtitle: Text(
item['description'] ?? '',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
onTap: () {
if (item['id'] == 'file-upload') {
return attachItem();
}
widget.onClose(
actionId: item['id'],
);
},
);
},
),
),
),
);
}
}

View file

@ -1,158 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
class ChatBubble extends StatelessWidget {
final String message;
final bool isUser;
final bool isComplete;
final VoidCallback? onCancel;
final bool isStreaming = false;
final int length;
const ChatBubble({
super.key,
required this.message,
required this.isUser,
this.isComplete = true,
this.onCancel,
this.length = 0,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
const SizedBox(width: 8),
Flexible(
child: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) => BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: AlertDialog(
contentPadding: const EdgeInsets.only(
bottom: 12,
),
insetPadding: const EdgeInsets.all(10),
title: const Text("Message"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
TextButton.icon(
onPressed: () {
Clipboard.setData(
ClipboardData(text: message),
);
},
label: const Text('Copy'),
icon: const Icon(Icons.copy),
),
],
content: Container(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height - 220,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 20),
MarkdownBody(
data: message,
selectable: true,
),
],
),
),
),
),
),
),
);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isUser
? Theme.of(context).primaryColor.withOpacity(0.9)
: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(20),
border: !isUser
? Border.all(color: Colors.grey.withOpacity(0.2))
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
message.trim().isEmpty && isUser
? Column(
children: [
const Icon(Icons.file_present),
Text("Files Attached $length"),
],
)
: MarkdownBody(
data: message,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? Colors.white
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: 16,
),
),
),
],
),
),
),
),
),
if (isUser) _buildAvatar(true),
],
),
],
),
);
}
Widget _buildAvatar(bool isUser) {
return CircleAvatar(
backgroundColor: isUser ? Colors.blue.shade700 : Colors.grey.shade900,
child: Icon(
isUser ? Icons.person : Icons.auto_awesome,
color: Colors.white,
),
);
}
}

View file

@ -1,150 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/chat/container/chat_empty_state.dart';
import 'chat_bubble.dart';
import 'chat_input_area.dart';
class ChatMessage {
final String message;
final bool isUser;
final bool isComplete;
final List<String> files;
final CancellationToken? cancellationToken;
final String? actionId;
ChatMessage({
required this.message,
required this.isUser,
this.files = const [],
this.isComplete = true,
this.cancellationToken,
this.actionId,
});
}
class CancellationToken {
bool _isCancelled = false;
bool get isCancelled => _isCancelled;
void cancel() => _isCancelled = true;
}
class ChatContainer extends StatefulWidget {
final List<ChatMessage>? initialMessages;
final Future<void> Function(String, List<String>?, String?)? onSendMessage;
final ScrollController scrollController;
const ChatContainer({
super.key,
this.initialMessages,
this.onSendMessage,
required this.scrollController,
});
@override
State<ChatContainer> createState() => _ChatContainerState();
}
class _ChatContainerState extends State<ChatContainer> {
List<ChatMessage> messages = [];
bool isLoading = false;
final FocusNode _focusNode = FocusNode();
bool _isExpanded = false;
@override
void initState() {
super.initState();
if (widget.initialMessages != null) {
messages = widget.initialMessages!;
}
_focusNode.addListener(() {
setState(() {
_isExpanded = _focusNode.hasFocus;
});
});
}
Future<void> _handleSubmit(
String text,
String? actionId,
List<String> files,
) async {
if (text.trim().isEmpty && actionId == null) return;
setState(() {
isLoading = true;
});
if (widget.onSendMessage != null) {
await widget.onSendMessage!(text, files, actionId);
}
setState(() {
isLoading = false;
files = [];
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
Widget _buildEmptyState() {
return ChatEmpty(
handleSubmit: (text) => _handleSubmit(
text,
null,
[],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: messages.isEmpty
? _buildEmptyState()
: Container(
constraints: const BoxConstraints(
maxWidth: 1100,
),
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.all(8.0),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return ChatBubble(
message: message.message,
isUser: message.isUser,
isComplete: message.isComplete,
length: message.files.length,
);
},
),
),
),
Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 1100,
),
child: ChatInputArea(
isLoading: isLoading,
onSubmitted: (text, files, actionId) => _handleSubmit(
text,
actionId,
files,
),
focusNode: _focusNode,
cancellationToken: messages.lastOrNull?.cancellationToken,
),
),
),
],
);
}
}

View file

@ -1,207 +0,0 @@
import 'package:flutter/material.dart';
class ChatEmpty extends StatefulWidget {
final Function(String input) handleSubmit;
const ChatEmpty({
super.key,
required this.handleSubmit,
});
@override
State<ChatEmpty> createState() => _ChatEmptyState();
}
class _ChatEmptyState extends State<ChatEmpty> {
@override
Widget build(BuildContext context) {
final suggestions = [
"📚 How to create a study schedule?",
"🧠 Best memory techniques for exams",
"⏰ Time management tips for exam prep",
"📝 Practice test strategies",
"📱 Best study apps and tools",
"🎯 How to stay focused while studying",
"💡 Active recall techniques",
"📊 Spaced repetition methods",
"🌟 Exam day preparation tips",
"✍️ Note-taking strategies",
"🧘‍♂️ Study break activities",
"👥 Group study benefits",
];
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final primaryColor =
isDarkMode ? Colors.blue[400]! : Theme.of(context).primaryColor;
return Center(
child: SingleChildScrollView(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated gradient container
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
primaryColor.withOpacity(0.3),
primaryColor.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: primaryColor.withOpacity(0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: primaryColor.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Icon(
Icons.chat_bubble_outline,
size: 48,
color: primaryColor,
),
),
const SizedBox(height: 32),
Text(
'How can I help you today?',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : primaryColor,
),
),
const SizedBox(height: 12),
Text(
'Choose a suggestion or type your own question',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
const SizedBox(height: 32),
SizedBox(
height: 100,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: suggestions
.sublist(0, suggestions.length ~/ 2)
.map((suggestion) => Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildSuggestionChip(
suggestion,
isDarkMode: isDarkMode,
primaryColor: primaryColor,
),
))
.toList(),
),
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: suggestions
.sublist(suggestions.length ~/ 2)
.map((suggestion) => Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildSuggestionChip(
suggestion,
isDarkMode: isDarkMode,
primaryColor: primaryColor,
),
))
.toList(),
),
),
),
],
),
),
],
),
),
),
),
);
}
Widget _buildSuggestionChip(
String suggestion, {
required bool isDarkMode,
required Color primaryColor,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => widget.handleSubmit(suggestion),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
isDarkMode ? Colors.grey[850]! : Theme.of(context).cardColor,
isDarkMode
? Colors.grey[900]!
: Theme.of(context).cardColor.withOpacity(0.9),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color: isDarkMode
? Colors.grey[800]!
: primaryColor.withOpacity(0.1),
),
boxShadow: [
if (!isDarkMode)
BoxShadow(
color: primaryColor.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: primaryColor,
),
const SizedBox(width: 8),
Flexible(
child: Text(
suggestion,
style: TextStyle(
color: isDarkMode ? Colors.grey[300] : primaryColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
}

View file

@ -1,157 +0,0 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/material.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
class ChatHistory extends StatefulWidget {
const ChatHistory({super.key});
@override
State<ChatHistory> createState() => _ChatHistoryState();
}
class _ChatHistoryState extends State<ChatHistory> {
final pb = AppEngine.engine.pb;
late final InfiniteQuery<List<RecordModel>, int> chatHistoryQuery;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
chatHistoryQuery = InfiniteQuery<List<RecordModel>, int>(
key: "chat_history",
queryFn: (page) async {
try {
final result = await pb.collection("chat").getList(
page: page,
perPage: 10, // Adjust perPage as needed
sort: '-created', // Assuming you want the latest chats first
);
return result.items.toList();
} catch (e) {
debugPrint('Error fetching chat history: $e');
throw e;
}
},
getNextArg: (state) {
if (state.lastPage?.isEmpty ?? false) return null;
return state.length;
},
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
"Chat History",
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
pb.collection("chat").create(
body: {},
);
},
icon: const Icon(
Icons.create_new_folder_outlined,
),
label: const Text("New Chat"),
),
const SizedBox(
width: 8,
),
],
),
const Divider(),
Expanded(
child: InfiniteQueryBuilder(
query: chatHistoryQuery,
builder: (ctx, state, query) {
if (state.status == QueryStatus.loading &&
state.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == QueryStatus.error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Failed to load chat history.',
style: TextStyle(color: Colors.red.shade700),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: query.refetch,
child: const Text('Retry'),
),
],
),
);
}
final items = state.data?.expand((e) => e).toList() ?? [];
if (items.isEmpty) {
return const Center(child: Text("No chat history yet."));
}
return RefreshIndicator(
onRefresh: query.refetch,
child: ListView.separated(
controller: _scrollController,
itemCount: items.length + (!state.hasReachedMax ? 1 : 0),
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
if (index < items.length) {
final chat = items[index];
return ListTile(
title: Text(
chat.getStringValue("title") ?? "Untitled Chat",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'Created: ${chat.created}', // Display creation date
style: Theme.of(context).textTheme.bodyLarge,
),
);
} else if (!query.hasReachedMax()) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
} else {
return const SizedBox.shrink();
}
},
),
);
},
),
),
],
),
),
);
}
}

View file

@ -1,328 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/chat/container/chat_container.dart';
import 'chat_action.dart';
class ChatInputArea extends StatefulWidget {
final bool isLoading;
final Future Function(
String text,
List<String> files,
String? actionId,
) onSubmitted;
final FocusNode focusNode;
final CancellationToken? cancellationToken;
const ChatInputArea({
super.key,
required this.isLoading,
required this.onSubmitted,
required this.focusNode,
this.cancellationToken,
});
@override
State<ChatInputArea> createState() => _ChatInputAreaState();
}
class _ChatInputAreaState extends State<ChatInputArea> {
final TextEditingController _textController = TextEditingController();
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
final Map<String, String> files =
{}; // key is file name and string is the content
String? actionId;
@override
void initState() {
super.initState();
_textController.addListener(_handleTextChange);
AppEngine.engine.pb
.collection("ai_action")
.getList(perPage: 50)
.then((docs) {});
}
@override
void dispose() {
_hideCommandPalette();
_textController.removeListener(_handleTextChange);
super.dispose();
}
void _handleTextChange() {
if (_textController.text == '/') {
_showCommandPalette();
} else if (_textController.text.isEmpty) {
_hideCommandPalette();
}
}
void _showCommandPalette() {
_hideCommandPalette();
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
width: MediaQuery.of(context).size.width,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, -5),
targetAnchor: Alignment.topCenter,
followerAnchor: Alignment.bottomCenter,
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
tween: Tween(begin: 0, end: 1),
builder: (context, value, child) => Transform.scale(
scale: value,
child: Opacity(
opacity: value,
child: child,
),
),
child: ChatAction(
actionId: actionId,
onClose: ({
actionId,
files,
}) {
_textController.clear();
_hideCommandPalette();
setState(() {
this.files.addAll(files ?? {});
if (actionId != null) this.actionId = actionId;
});
},
),
),
),
),
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {});
}
void _hideCommandPalette() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted && context.mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (files.isNotEmpty)
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: files.length,
itemBuilder: (context, index) {
final fileName = files.keys.elementAt(index);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
insetPadding: const EdgeInsets.all(4),
actions: [
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
},
label: const Text("Close"),
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
},
label: const Text("Copy"),
icon: const Icon(Icons.copy),
),
],
title: Text(
fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
content: SingleChildScrollView(
child: Text(
files[fileName]!,
),
),
),
);
},
child: Chip(
visualDensity: VisualDensity.compact,
label: Row(
children: [
const Icon(
Icons.picture_as_pdf_outlined,
size: 16,
),
const SizedBox(
width: 12,
),
Container(
constraints: const BoxConstraints(
maxWidth: 100,
),
child: Text(
fileName,
style: const TextStyle(
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
onDeleted: () {
setState(() {
files.remove(fileName);
});
},
deleteIcon: const Icon(Icons.close, size: 16),
backgroundColor:
Theme.of(context).primaryColor.withOpacity(0.1),
),
),
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: _overlayEntry == null
? const Icon(Icons.add_circle_outline)
: const Icon(Icons.close),
onPressed: _overlayEntry == null
? _showCommandPalette
: _hideCommandPalette,
tooltip: 'Open commands',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
const SizedBox(
width: 8,
),
Expanded(
child: CompositedTransformTarget(
link: _layerLink,
child: Container(
constraints: const BoxConstraints(
minHeight: 40,
maxHeight: 120,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
controller: _textController,
focusNode: widget.focusNode,
maxLines: null,
decoration: const InputDecoration(
hintText: 'Message...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
),
onSubmitted: (value) async {
await widget.onSubmitted(
value,
files.values.toList(),
actionId,
);
setState(() {
files.clear();
actionId = null;
});
_textController.clear();
},
enabled: !widget.isLoading,
),
),
),
),
),
const SizedBox(width: 8),
Container(
margin: const EdgeInsets.only(bottom: 2),
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: _textController.text.isEmpty ? 0.8 : 1.0,
child: Container(
height: 40,
width: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.isLoading
? Colors.grey
: Theme.of(context).primaryColor.withOpacity(0.9),
),
child: widget.cancellationToken == null ||
widget.cancellationToken?.isCancelled == true
? IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.send,
color: Colors.white, size: 20),
onPressed: widget.isLoading
? null
: () async {
await widget.onSubmitted(
_textController.text,
files.values.toList(),
actionId,
);
setState(() {
files.clear();
actionId = null;
});
_textController.clear();
},
)
: IconButton(
onPressed: () {
widget.cancellationToken?.cancel();
},
icon: const Icon(
Icons.stop_circle,
),
),
),
),
),
],
),
),
],
);
}
}

View file

@ -1,217 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/utils/ocr_file.dart';
import '../service/service.dart';
class AddCollectionItemSheet extends StatefulWidget {
final String listId;
const AddCollectionItemSheet({
super.key,
required this.listId,
});
@override
State<AddCollectionItemSheet> createState() => _AddCollectionItemSheetState();
}
class _AddCollectionItemSheetState extends State<AddCollectionItemSheet>
with SingleTickerProviderStateMixin {
final _titleController = TextEditingController();
late TabController _tabController;
PlatformFile? _selectedFile;
String? _fileType;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_titleController.dispose();
_tabController.dispose();
super.dispose();
}
Future<void> _pickFile() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null) {
setState(() {
_selectedFile = result.files.single;
_fileType = result.files.single.extension;
_titleController.text = result.files.single.name;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error picking file: $e')),
);
}
}
}
bool _isLoading = false;
Future<void> _saveItem() async {
if (_titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a title')),
);
return;
}
if (_selectedFile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a file')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
await CollectionService.addItem(
listId: widget.listId,
name: _titleController.text,
type: "file",
file: _selectedFile!,
content: (await ocrFiles([_selectedFile!])).first,
);
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add New Item'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
),
floatingActionButton: FloatingActionButton.extended(
label: _isLoading ? const Text("Uploading...") : const Text("Upload"),
icon: _isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.upload_file),
onPressed: _isLoading ? null : _saveItem,
),
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFileUploadTab(),
],
),
);
}
Widget _buildFileUploadTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_selectedFile != null)
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
_selectedFile == null
? Center(
child: Column(
children: [
const Icon(Icons.upload_file,
size: 48, color: Colors.grey),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.add),
label: const Text('Select File'),
),
const SizedBox(height: 8),
const Text(
'Supported formats: PDF, JPG, PNG',
style: TextStyle(color: Colors.grey),
),
],
),
)
: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_fileType == 'pdf'
? Icons.picture_as_pdf
: Icons.image,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedFile!.path?.split('/').last ?? "",
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() {
_selectedFile = null;
_fileType = null;
}),
),
],
),
const SizedBox(height: 8),
Text(
'File type: ${_fileType?.toUpperCase()}',
style: const TextStyle(color: Colors.grey),
),
],
),
),
),
],
),
);
}
}

View file

@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import '../types/collection_item_model.dart';
import 'collection_markdown_renderer.dart';
class CollectionItemRenderer extends StatelessWidget {
final CollectionItemModel item;
const CollectionItemRenderer({
super.key,
required this.item,
});
@override
Widget build(BuildContext context) {
switch (item.type) {
case 'markdown':
return MarkdownRenderer(content: item.content?['text'] ?? '');
// case 'file':
// return FileRenderer(filePath: item.file!);
default:
return Text('Unsupported type: ${item.type}');
}
}
}

View file

@ -1,237 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import '../../../engine/engine.dart';
import '../service/service.dart';
import '../types/collection_item_model.dart';
import 'add_collection_item.dart';
import 'collection_markdown_renderer.dart';
import 'collection_search_delegate.dart';
class CollectionListItemsScreen extends StatefulWidget {
final String listId;
final bool isPublic;
final String title;
const CollectionListItemsScreen({
super.key,
required this.listId,
required this.isPublic,
required this.title,
});
@override
State<CollectionListItemsScreen> createState() =>
_CollectionListItemsScreenState();
}
class _CollectionListItemsScreenState extends State<CollectionListItemsScreen> {
String _sortBy = 'created';
bool _ascending = false;
late Future<List<CollectionItemModel>> _itemsFuture;
Future<void> _showAddItemSheet() async {
final result = await showModalBottomSheet(
context: context,
builder: (context) => AddCollectionItemSheet(listId: widget.listId),
);
if (result == true) {
setState(() => _refreshItems());
}
}
@override
void initState() {
super.initState();
_refreshItems();
}
void _refreshItems() {
_itemsFuture = CollectionService.getCollectionItems(
listId: widget.listId,
searchQuery: '',
sortBy: _sortBy,
ascending: _ascending,
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<CollectionItemModel>>(
future: _itemsFuture,
builder: (context, snapshot) {
return Scaffold(
floatingActionButton: snapshot.hasData &&
!widget.isPublic &&
!kIsWeb &&
(Platform.isIOS || Platform.isAndroid)
? FloatingActionButton(
onPressed: _showAddItemSheet,
child: const Icon(Icons.add),
)
: null,
appBar: AppBar(
title: Text(widget.title),
actions: [
// Search action
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: CollectionSearchDelegate(widget.listId),
);
},
),
// Sort menu
PopupMenuButton<String>(
icon: const Icon(Icons.sort),
onSelected: (value) {
setState(() {
if (value == _sortBy) {
_ascending = !_ascending;
} else {
_sortBy = value;
}
_refreshItems();
});
},
itemBuilder: (context) => [
CheckedPopupMenuItem(
value: 'created',
checked: _sortBy == 'created',
child: Row(
children: [
const Text('Created'),
if (_sortBy == 'created')
Icon(_ascending
? Icons.arrow_upward
: Icons.arrow_downward),
],
),
),
CheckedPopupMenuItem(
value: 'updated',
checked: _sortBy == 'updated',
child: Row(
children: [
const Text('Updated'),
if (_sortBy == 'updated')
Icon(_ascending
? Icons.arrow_upward
: Icons.arrow_downward),
],
),
),
],
),
// Refresh button
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => setState(() => _refreshItems()),
),
],
),
body: FutureBuilder(
future: Future.delayed(Duration.zero),
builder: (ctx, _) {
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
],
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final items = snapshot.data!;
if (items.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text('No items found'),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
onTap: () async {
if (item.file != null && item.file != "") {
final fileToken =
await AppEngine.engine.pb.files.getToken();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return DocViewer(
source: URLSource(
title: item.name,
url: "${item.file}?token=$fileToken",
id: item.id,
),
);
},
),
);
return;
}
final file = await AppEngine.engine.pb
.collection("collection_item")
.getOne(item.id);
if (context.mounted && mounted) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FullMarkdownSheet(
content: file.getStringValue("content") ?? "",
),
);
}
},
leading: item.type == "markdown"
? const Icon(Icons.document_scanner_outlined)
: const Icon(Icons.file_present),
title: Text(item.name),
subtitle: Text(formatDate(item.updated)),
);
},
);
},
),
);
},
);
}
String formatDate(DateTime created) {
return DateFormat("dd MMM yyyy").format(created);
}
}

View file

@ -1,155 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
class MarkdownRenderer extends StatelessWidget {
final String content;
final int previewLines;
const MarkdownRenderer({
super.key,
required this.content,
this.previewLines = 3,
});
@override
Widget build(BuildContext context) {
final String previewText =
content.split('\n').take(previewLines).join('\n');
final bool hasMore = content.split('\n').length > previewLines;
return InkWell(
onTap: () => _showFullMarkdown(context),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MarkdownBody(
data: previewText,
shrinkWrap: true,
styleSheet: MarkdownStyleSheet(
p: Theme.of(context).textTheme.bodyMedium,
h1: Theme.of(context).textTheme.headlineSmall,
h2: Theme.of(context).textTheme.titleLarge,
),
),
if (hasMore) ...[
const SizedBox(height: 8),
Text(
'Tap to read more...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
),
);
}
void _showFullMarkdown(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FullMarkdownSheet(content: content),
);
}
}
class FullMarkdownSheet extends StatelessWidget {
final String content;
const FullMarkdownSheet({
super.key,
required this.content,
});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Actions bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
// Copy to clipboard
// You might want to add feedback when copied
},
),
IconButton(
icon: const Icon(Icons.share),
onPressed: () {
// Implement share functionality
},
),
],
),
],
),
),
// Markdown content
Expanded(
child: Markdown(
controller: scrollController,
data: content,
styleSheet: MarkdownStyleSheet(
p: Theme.of(context).textTheme.bodyLarge,
h1: Theme.of(context).textTheme.headlineMedium,
h2: Theme.of(context).textTheme.headlineSmall,
h3: Theme.of(context).textTheme.titleLarge,
code: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
backgroundColor: Colors.grey[200],
),
codeblockDecoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
),
selectable: true,
padding: const EdgeInsets.all(16),
),
),
],
),
);
},
);
}
}

View file

@ -1,149 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../engine/engine.dart';
import '../../doc_viewer/container/doc_viewer.dart';
import '../../doc_viewer/types/doc_source.dart';
import '../service/service.dart';
import '../types/collection_item_model.dart';
import 'collection_markdown_renderer.dart';
class CollectionSearchDelegate extends SearchDelegate<String> {
final String listId;
CollectionSearchDelegate(this.listId);
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
close(context, '');
},
);
}
@override
Widget buildResults(BuildContext context) {
return _buildSearchResults();
}
@override
Widget buildSuggestions(BuildContext context) {
return _buildSearchResults();
}
Widget _buildSearchResults() {
if (query.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Start typing to search'),
],
),
);
}
return FutureBuilder<List<CollectionItemModel>>(
future: CollectionService.getCollectionItems(
listId: listId,
searchQuery: query,
sortBy: 'created',
ascending: false,
),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final items = snapshot.data!;
if (items.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No results found'),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
onTap: () async {
if (item.file != null && item.file != "") {
final fileToken = await AppEngine.engine.pb.files.getToken();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return DocViewer(
source: URLSource(
title: item.name,
url: "${item.file}?token=$fileToken",
id: item.id,
),
);
},
),
);
return;
}
final file = await AppEngine.engine.pb
.collection("collection_item")
.getOne(item.id);
if (context.mounted) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FullMarkdownSheet(
content: file.getStringValue("content") ?? "",
),
);
}
},
leading: item.type == "markdown"
? const Icon(Icons.document_scanner_outlined)
: const Icon(Icons.file_present),
title: Text(item.name),
subtitle: Text(formatDate(item.updated)),
);
},
);
},
);
}
String formatDate(DateTime created) {
return DateFormat("dd MMM yyyy").format(created);
}
}

View file

@ -1,147 +0,0 @@
import 'package:flutter/material.dart';
import '../../../engine/engine.dart';
class CreateCollectionBottomSheet extends StatefulWidget {
final Function() onCollectionCreated;
const CreateCollectionBottomSheet({
super.key,
required this.onCollectionCreated,
});
@override
State<CreateCollectionBottomSheet> createState() =>
_CreateCollectionBottomSheetState();
}
class _CreateCollectionBottomSheetState
extends State<CreateCollectionBottomSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isPublic = false;
bool _isLoading = false;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _createCollection() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final pb = AppEngine.engine.pb;
await pb.collection('collection').create(body: {
'name': _nameController.text.trim(),
'description': _descriptionController.text.trim(),
'is_public': _isPublic,
'order': 0,
'user': pb.authStore.record!.id,
});
if (mounted) {
Navigator.pop(context);
widget.onCollectionCreated();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create collection: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Create New Collection',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Collection Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a name';
}
return null;
},
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (Optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Make Public'),
subtitle: const Text('Allow others to view this collection'),
value: _isPublic,
onChanged: (value) => setState(() => _isPublic = value),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _createCollection,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Collection'),
),
const SizedBox(height: 16),
],
),
),
);
}
}

View file

@ -1,116 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
import '../../../engine/engine.dart';
import '../types/collection_item_model.dart';
class CollectionService {
static Future<List<CollectionItemModel>> getCollectionItems({
required String listId,
String searchQuery = '',
String sortBy = 'created',
bool ascending = false,
}) async {
try {
final List<String> filters = ['list = "$listId"'];
if (searchQuery.isNotEmpty) {
filters.add('name ~ "$searchQuery"');
}
final String sort = ascending ? sortBy : '-$sortBy';
final result =
await AppEngine.engine.pb.collection('collection_item').getList(
filter: filters.join(' && '),
sort: sort,
fields: "file, id, name, type, updated, list, user, created",
);
return result.items.map(
(item) {
final res = CollectionItemModel.fromJson(item.toJson());
if (res.file != null) {
final url = AppEngine.engine.pb.files.getURL(
item,
item.getStringValue('file'),
);
res.file = url.toString().replaceFirst(
"api/files//",
"api/files/pbc_2910457697/",
);
}
return res;
},
).toList();
} catch (e) {
print('Error fetching collection items: $e');
rethrow;
}
}
// Add new item
static Future<CollectionItemModel> addItem({
required String listId,
required String name,
required String type,
PlatformFile? file,
dynamic content,
}) async {
final data = {
'list': listId,
'name': name,
'type': type,
'content': content,
'user': AppEngine.engine.pb.authStore.record!.id,
};
final record =
await AppEngine.engine.pb.collection('collection_item').create(
body: data,
files: [
if (file != null)
http.MultipartFile.fromBytes(
"file",
(file.bytes)!.toList(),
filename: basename(file.path!),
),
],
);
return CollectionItemModel.fromJson(record.toJson());
}
// Update existing item
static Future<CollectionItemModel> updateItem({
required String itemId,
String? name,
String? type,
String? file,
Map<String, dynamic>? content,
}) async {
final data = {
if (name != null) 'name': name,
if (type != null) 'type': type,
if (file != null) 'file': file,
if (content != null) 'content': content,
};
final record =
await AppEngine.engine.pb.collection('collection_item').update(
itemId,
body: data,
);
return CollectionItemModel.fromJson(record.toJson());
}
// Delete item
static Future<void> deleteItem(String itemId) async {
await AppEngine.engine.pb.collection('collection_item').delete(itemId);
}
}

View file

@ -1,43 +0,0 @@
class CollectionItemModel {
final String id;
final String name;
String? file;
final String listId;
final String userId;
final dynamic content;
final String type;
final DateTime created;
final DateTime updated;
CollectionItemModel({
required this.id,
required this.name,
this.file,
required this.listId,
required this.userId,
this.content,
required this.type,
required this.created,
required this.updated,
});
factory CollectionItemModel.fromJson(Map<String, dynamic> json) {
try {
return CollectionItemModel(
id: json['id'],
name: json['name'],
file: json['file'],
listId: json['list'],
userId: json['user'],
content: json['content'],
type: json['type'],
created: DateTime.parse(json['created']),
updated: DateTime.parse(json['updated']),
);
} catch (e, stack) {
print(e);
print(stack);
rethrow;
}
}
}

View file

@ -1,313 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
import '../container/collection_list_item_list.dart';
class CollectionListModel {
final String id;
final String collectionId;
final String name;
final String? description;
final int order;
final String? background;
final DateTime created;
final DateTime updated;
final String userId;
final bool isPublic;
CollectionListModel({
required this.id,
required this.collectionId,
required this.name,
this.description,
required this.order,
this.background,
required this.created,
required this.updated,
required this.userId,
required this.isPublic,
});
factory CollectionListModel.fromRecord(RecordModel record) {
return CollectionListModel(
id: record.id,
collectionId: record.collectionId,
name: record.data['name'],
description: record.data['description'],
order: record.data['order'],
background: record.data['background'],
created: DateTime.parse(record.get("created")),
updated: DateTime.parse(record.get("updated")),
userId: record.data['user'],
isPublic: record.data['isPublic'],
);
}
}
class CollectionCard extends StatefulWidget {
final CollectionListModel collection;
final double width;
const CollectionCard({
super.key,
required this.collection,
this.width = 480,
});
@override
State<CollectionCard> createState() => _CollectionCardState();
}
class _CollectionCardState extends State<CollectionCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _controller.forward(),
onExit: (_) => _controller.reverse(),
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
child: Container(
width: widget.width,
height: widget.width * .6,
margin: const EdgeInsets.all(12),
child: Card(
elevation: 8,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
// Background Image or Gradient
_buildBackground(),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.8),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
// Collection Name
Text(
widget.collection.name,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 3,
color: Colors.black45,
),
],
),
),
const SizedBox(height: 8),
// Description
if (widget.collection.description != null)
Text(
widget.collection.description!,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 2,
color: Colors.black45,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
// Metadata Row
Row(
children: [
_buildMetadataChip(
Icons.calendar_today,
_formatDate(widget.collection.updated),
),
const Spacer(),
_buildInteractionButton(),
],
),
],
),
),
// Optional: Add a subtle overlay pattern
CustomPaint(
painter: PatternPainter(),
),
// Material Ink Effect for Ripple
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) => CollectionListItemsScreen(
listId: widget.collection.id,
title: widget.collection.name,
isPublic: widget.collection.isPublic,
),
),
);
},
),
),
],
),
),
),
),
);
}
Widget _buildBackground() {
if (widget.collection.background != null) {
return Hero(
tag: 'collection-${widget.collection.id}',
child: Image.network(
'${AppEngine.engine.pb.baseURL}/api/files/${widget.collection.collectionId}/${widget.collection.id}/${widget.collection.background}',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildFallbackBackground(),
),
);
}
return _buildFallbackBackground();
}
Widget _buildFallbackBackground() {
// Generate a unique but consistent color based on collection ID
final color = Color(
(widget.collection.id.hashCode & 0xFFFFFF) | 0xFF000000,
);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color,
Color.lerp(color, Colors.white, 0.2)!,
],
),
),
child: const Icon(
Icons.collections,
size: 80,
color: Colors.white24,
),
);
}
Widget _buildMetadataChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(color: Colors.white70),
),
],
),
);
}
Widget _buildInteractionButton() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.arrow_forward, color: Colors.black87),
visualDensity: VisualDensity.compact,
onPressed: () {
// Handle interaction
},
),
);
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
}
// Custom Pattern Painter for subtle overlay
class PatternPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.05)
..strokeWidth = 1;
for (var i = 0; i < size.width; i += 20) {
for (var j = 0; j < size.height; j += 20) {
canvas.drawCircle(Offset(i.toDouble(), j.toDouble()), 1, paint);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

View file

@ -0,0 +1,71 @@
import 'package:pocketbase/pocketbase.dart';
String getErrorMessage(ClientException e) {
switch (e.statusCode) {
case 400:
final data = e.response['data'];
if (data?['email'] != null) {
switch (data['email']['code']) {
case 'validation_invalid_email':
return 'Please enter a valid email address';
case 'validation_not_unique':
return 'This email is already registered';
default:
return 'Invalid email';
}
}
if (data?['password'] != null) {
switch (data['password']['code']) {
case 'validation_length_out_of_range':
return 'Password must be at least 6 characters long';
case 'validation_too_weak':
return 'Password is too weak. Please include numbers and special characters';
default:
return 'Invalid password';
}
}
if (data?['passwordConfirm'] != null) {
switch (data['passwordConfirm']['code']) {
case 'validation_values_mismatch':
return 'Passwords do not match';
default:
return 'Password confirmation error';
}
}
if (data?['username'] != null || data?['name'] != null) {
return 'Please enter a valid name';
}
return 'Please check your input and try again';
case 401:
return 'Invalid credentials';
case 403:
return 'You don\'t have permission to perform this action';
case 404:
return 'Resource not found';
case 429:
return 'Too many attempts. Please try again later';
case 500:
return 'Server error. Please try again later';
case 503:
return 'Service temporarily unavailable. Please try again later';
default:
final message = e.response['message'];
if (message != null && message.toString().isNotEmpty) {
return message.toString();
}
return 'An error occurred. Please try again';
}
}

View file

@ -1,11 +1,11 @@
import '../engine/engine.dart';
import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart';
Future<void> refreshAuth() async {
final pb = AppEngine.engine.pb;
final pb = AppPocketBaseService.instance.pb;
final userCollection = pb.collection("users");
final user = await userCollection.getOne(
AppEngine.engine.pb.authStore.record!.id,
pb.authStore.record!.id,
);
pb.authStore.save(pb.authStore.token, user);
}

View file

@ -0,0 +1,42 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:cached_storage/cached_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:window_manager/window_manager.dart';
import '../../pocketbase/service/pocketbase.service.dart';
import '../../widgetter/plugin_base.dart';
import '../../widgetter/plugins/stremio/stremio_plugin.dart';
final _logger = Logger('StartupApp');
Future startupApp() async {
MediaKit.ensureInitialized();
await AppPocketBaseService.ensureInitialized();
if (UniversalPlatform.isDesktop) {
await windowManager.ensureInitialized();
}
if (kDebugMode) {
PluginRegistry.instance.reset();
}
PluginRegistry.instance.registerPlugin(
StremioCatalogPlugin(),
);
try {
CachedQuery.instance.configFlutter(
storage: await CachedStorage.ensureInitialized(),
config: QueryConfigFlutter(
refetchDuration: const Duration(minutes: 60),
cacheDuration: const Duration(minutes: 60),
),
);
} catch (e) {
_logger.warning("Unable initialize cache");
}
}

View file

@ -1,201 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/engine/library.dart';
import 'package:madari_client/features/connection/services/stremio_service.dart';
import '../../../engine/engine.dart';
import '../../settings/types/connection.dart';
class AutoImport extends StatefulWidget {
final Connection item;
final VoidCallback? onImport;
const AutoImport({
super.key,
required this.item,
this.onImport,
});
@override
State<AutoImport> createState() => _AutoImportState();
}
class _AutoImportState extends State<AutoImport> {
late StremioService _stremio;
final List<FolderItem> _selected = [];
bool _isLoading = false;
bool _selectAll = false;
Future<List<FolderItem>>? _folders;
@override
void initState() {
super.initState();
initialValueImport();
}
void initialValueImport() {
if ("stremio_addons" == widget.item.type) {
_stremio = StremioService(
connectionId: Future.delayed(
Duration.zero,
() => widget.item.id,
),
config: widget.item.config!,
);
_folders = _stremio.getFolders();
}
}
void createLibraryInBulk() async {
setState(() {
_isLoading = true;
});
int loaded = 0;
for (var item in _selected) {
try {
await AppEngine.engine.pb.collection("library").create(body: {
"title": item.title,
"icon": Icons.video_library.codePoint.toString(),
"types": ["video"],
"user": AppEngine.engine.pb.authStore.record!.id,
"config": [item.config ?? item.id],
"connection": widget.item.id,
});
loaded += 1;
} catch (e, stack) {
if (kDebugMode) print("Failed to $e");
if (kDebugMode) print(stack);
}
}
if (context.mounted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Imported Libraries $loaded failed ${_selected.length - loaded}",
),
),
);
setState(() {
_isLoading = false;
});
if (widget.onImport != null) widget.onImport!();
}
}
void toggleSelectAll() async {
final folders = await _folders;
if (folders == null) return;
setState(() {
_selectAll = !_selectAll;
if (_selectAll) {
_selected.clear();
_selected.addAll(folders);
} else {
_selected.clear();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
title: const Text("Import Libraries"),
backgroundColor: Colors.transparent,
actions: [
IconButton(
icon: Icon(_selectAll
? Icons.check_box_outlined
: Icons.check_box_outline_blank),
onPressed: () {
toggleSelectAll();
},
),
const SizedBox(
width: 12,
),
ElevatedButton.icon(
onPressed: _selected.isNotEmpty
? () {
createLibraryInBulk();
}
: null,
label: const Text("Import"),
icon: _isLoading
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
)
: const Icon(Icons.save),
),
const SizedBox(
width: 6,
),
],
),
body: FutureBuilder(
future: _folders,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text("Error: ${snapshot.error}");
}
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
final folders = snapshot.data!;
return ListView.builder(
itemCount: folders.length,
itemBuilder: (context, index) {
final item = folders[index];
final isSelected =
_selected.any((selected) => selected.id == item.id);
return ListTile(
onTap: () {
setState(() {
if (isSelected) {
_selected
.removeWhere((selected) => selected.id == item.id);
} else {
_selected.add(item);
}
});
},
leading: isSelected
? const Icon(Icons.check)
: const Icon(Icons.check_box_outline_blank),
title: Text(item.title),
);
},
);
},
),
);
}
}
class AutoImportData {
final String id;
final String title;
AutoImportData({
required this.id,
required this.title,
});
}

View file

@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/engine/connection_type.dart';
class ConfigureNeoConnection extends StatefulWidget {
final ConnectionTypeRecord item;
final void Function(String id) onConnectionComplete;
const ConfigureNeoConnection({
super.key,
required this.item,
required this.onConnectionComplete,
});
@override
State<ConfigureNeoConnection> createState() => _ConfigureNeoConnectionState();
}
class _ConfigureNeoConnectionState extends State<ConfigureNeoConnection> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _saveConnection() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _isLoading = true);
try {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connection saved successfully'),
backgroundColor: Colors.green,
),
);
// widget.onConnectionComplete("");
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving connection: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _urlController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Server URL',
prefixIcon: Icon(Icons.sensors_rounded),
hintText: 'https://neo.example.com',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter server URL';
}
if (!(Uri.tryParse(value)?.hasAuthority ?? true)) {
return 'Please enter a valid URL';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter password';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _saveConnection,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Connection'),
),
],
),
),
);
}
}

View file

@ -1,306 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:madari_client/engine/connection_type.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
import '../../settings/types/connection.dart';
class StremioAddonConnection extends StatefulWidget {
final void Function(Connection connection) onConnectionComplete;
final ConnectionTypeRecord item;
const StremioAddonConnection({
super.key,
required this.onConnectionComplete,
required this.item,
});
@override
State<StremioAddonConnection> createState() => _StremioAddonConnectionState();
}
class _StremioAddonConnectionState extends State<StremioAddonConnection> {
final PocketBase pb = AppEngine.engine.pb;
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
final _nameController = TextEditingController(text: "Stremio");
static const String cinemetaURL =
'https://v3-cinemeta.strem.io/manifest.json';
bool _isLoading = false;
String? _errorMessage;
final List<Map<String, dynamic>> _addons = [];
Future<void> _validateAddonUrl(String url) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final manifest = json.decode(response.body);
if (manifest['name'] == null || manifest['id'] == null) {
throw 'Invalid addon manifest';
}
if (_addons.any((addon) => addon['url'] == url)) {
throw 'Addon already added to the list';
}
setState(() {
_addons.add({
'name': manifest['name'],
'icon': manifest['logo'] ?? manifest['icon'],
'url': url,
});
_urlController.clear();
});
} else {
throw 'Failed to fetch addon manifest';
}
} catch (e) {
if (e is FormatException) {
setState(() {
_errorMessage = 'Invalid addon URL';
});
} else {
setState(() {
_errorMessage = 'Invalid addon URL: ${e.toString()}';
});
}
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _saveAddons() async {
if (!_formKey.currentState!.validate() || _addons.isEmpty) return;
try {
setState(() => _isLoading = true);
final body = await pb.collection('connection').create(body: {
'title': _nameController.text,
'user': pb.authStore.record!.id,
'type': widget.item.id,
'config': {
'addons': _addons
.map(
(item) => item["url"],
)
.toList()
},
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Saved successfully"),
),
);
}
widget.onConnectionComplete(
Connection(
title: body.getStringValue("title"),
id: body.id,
config: jsonEncode({
'addons': _addons
.map(
(item) => item["url"],
)
.toList()
}),
type: "stremio_addons",
),
);
} catch (e) {
if (e is ClientException) {
final response = e.response["data"];
final result = response.values.map((item) => item["message"]).join(" ");
setState(() {
if (kDebugMode) print(result);
_errorMessage = "Error: $result";
});
return;
}
setState(() {
_errorMessage = "Error: ${e.toString()}";
});
} finally {
setState(() => _isLoading = false);
}
}
void _removeAddon(int index) {
setState(() {
_addons.removeAt(index);
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 16.0,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Connection name',
),
validator: (value) {
if (value != null && value.isNotEmpty) {
return null;
}
return "Connection name is required";
},
),
const SizedBox(
height: 12,
),
TextFormField(
controller: _urlController,
decoration: InputDecoration(
labelText: 'Addon URL',
hintText: 'https://example.com/manifest.json',
suffixIcon: IconButton(
icon: const Icon(Icons.add),
onPressed: () => _validateAddonUrl(_urlController.text),
),
),
validator: (value) {
if (_addons.isEmpty) {
return 'Please add at least one addon';
}
if (value != null && value.isNotEmpty) {
try {
final uri = Uri.parse(value);
if (!uri.isScheme('http') && !uri.isScheme('https')) {
return 'Please enter a valid HTTP/HTTPS URL';
}
} catch (e) {
return 'Please enter a valid URL';
}
}
return null;
},
),
if (_isLoading) const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(
top: 8.0,
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
const SizedBox(
height: 12,
),
Text(
'Suggested Addons:',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(
height: 6,
),
Row(
children: [
ActionChip.elevated(
label: const Text("Cinemeta"),
onPressed: () {
_validateAddonUrl(cinemetaURL);
},
avatar: const Icon(Icons.extension),
)
],
),
if (_addons.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Added Addons:',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(
height: 6,
),
ListView.builder(
shrinkWrap: true,
itemCount: _addons.length,
itemBuilder: (context, index) {
final addon = _addons[index];
return Container(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
margin: EdgeInsets.zero,
child: ListTile(
leading: addon['icon'] != null
? Image.network(
addon['icon'],
width: 40,
height: 40,
errorBuilder: (_, __, ___) =>
const Icon(Icons.extension),
)
: const Icon(
Icons.extension,
size: 40,
),
title: Text(addon['name']),
subtitle: Text(
addon['url'],
maxLines: 1,
),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _removeAddon(index),
color: Colors.red,
),
),
),
);
},
),
],
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addons.isNotEmpty && !_isLoading ? _saveAddons : null,
child: const Text('Save Configuration'),
),
],
),
),
);
}
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
}

View file

@ -1,265 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:madari_client/features/connection/containers/auto_import.dart';
import 'package:madari_client/features/settings/types/connection.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
import '../../../engine/library.dart';
import '../../library/screen/create_new_library.dart';
class ConnectionManager extends StatefulWidget {
final Connection item;
const ConnectionManager({
super.key,
required this.item,
});
@override
State<ConnectionManager> createState() => _ConnectionManagerState();
}
class _ConnectionManagerState extends State<ConnectionManager> {
final PocketBase pb = AppEngine.engine.pb;
late Future<ResultList<RecordModel>> _items;
bool _isLoading = false;
bool _isDragging = false;
@override
void initState() {
super.initState();
_refreshItems();
}
void _refreshItems() {
setState(() {
_items = pb.collection("library").getList(
filter: "connection.id = ${jsonEncode(widget.item.id)}",
sort: "+order",
);
});
}
Future<void> _updateOrder(
int oldIndex, int newIndex, List<RecordModel> items) async {
setState(() => _isDragging = true);
try {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
// Update order for all affected items
for (int i = 0; i < items.length; i++) {
await pb.collection("library").update(
items[i].id,
body: {"order": i},
);
}
} finally {
setState(() => _isDragging = false);
_refreshItems();
}
}
Future<void> _showEditDialog(RecordModel item) async {
final TextEditingController titleController = TextEditingController(
text: item.getStringValue("title"),
);
return showDialog(
context: context,
barrierDismissible: !_isLoading,
builder: (context) => AlertDialog(
title: const Text('Edit Library'),
content: TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Library Name'),
autofocus: true,
enabled: !_isLoading,
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: _isLoading
? null
: () async {
setState(() => _isLoading = true);
try {
await pb.collection("library").update(
item.id,
body: {"title": titleController.text},
);
if (context.mounted) if (mounted) Navigator.pop(context);
_refreshItems();
} finally {
setState(() => _isLoading = false);
}
},
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
),
);
}
Future<void> _confirmDelete(RecordModel item) async {
return showDialog(
context: context,
barrierDismissible: !_isLoading,
builder: (context) => AlertDialog(
title: const Text('Confirm Delete'),
content: const Text('Are you sure you want to delete this library?'),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: _isLoading
? null
: () async {
setState(() => _isLoading = true);
try {
await pb.collection("library").delete(item.id);
if (mounted && context.mounted) Navigator.pop(context);
_refreshItems();
} finally {
setState(() => _isLoading = false);
}
},
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Connection ${widget.item.title}"),
actions: [
const SizedBox(width: 6),
ElevatedButton.icon(
onPressed: _isDragging || _isLoading
? null
: () {
showModalBottomSheet(
context: context,
builder: (ctx) => AutoImport(item: widget.item),
).then((_) => _refreshItems());
},
label: const Text("Auto Import"),
icon: const Icon(Icons.auto_awesome),
),
const SizedBox(width: 10),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _isDragging || _isLoading
? null
: () {
showModalBottomSheet(
context: context,
builder: (context) => Consumer(
builder: (context, ref, child) => CreateNewLibrary(
item: widget.item,
onCreatedAnother: () {},
onCreated: () {
Navigator.pop(context);
ref.refresh(libraryListProvider(1).future);
_refreshItems();
},
),
),
);
},
label: const Text("Add new library"),
icon: const Icon(Icons.add),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: FutureBuilder(
future: _items,
builder: (ctx, result) {
if (result.hasError) {
return Center(
child: Text("Error: ${result.error}"),
);
}
if (!result.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
final items = result.data!.items;
return ReorderableListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: items.length,
onReorder: (oldIndex, newIndex) =>
_updateOrder(oldIndex, newIndex, items),
itemBuilder: (context, index) {
final item = items[index];
return Card(
key: ValueKey(item.id),
margin:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text(
item.getStringValue("title"),
style: Theme.of(context).textTheme.titleMedium,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: _isDragging || _isLoading
? null
: () => _showEditDialog(item),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _isDragging || _isLoading
? null
: () => _confirmDelete(item),
),
],
),
),
);
},
);
},
),
),
),
);
}
}

View file

@ -1,208 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:madari_client/engine/connection_type.dart';
import 'package:pocketbase/pocketbase.dart';
class CreateNewConnection extends StatefulWidget {
final void Function(ConnectionTypeRecord record)? onCallback;
const CreateNewConnection({
super.key,
this.onCallback,
});
@override
State<CreateNewConnection> createState() => _CreateNewConnectionState();
}
class _CreateNewConnectionState extends State<CreateNewConnection> {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
minHeight: MediaQuery.of(context).size.height * 0.5,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetHandle(),
_buildHeader(context),
Expanded(
child: Consumer(
builder: (context, ref, child) {
final activity = ref.watch(connectionTypeListProvider(page: 1));
return activity.when(
data: (result) => _buildConnectionList(result),
error: (error, trace) => _buildError(error),
loading: () => const Center(
child: CircularProgressIndicator(),
),
);
},
),
),
],
),
);
}
Widget _buildBottomSheetHandle() {
return Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Column(
children: [
Text(
"Add New Connection",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Select a connection type to configure",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
);
}
Widget _buildConnectionList(ResultList<ConnectionTypeRecord> result) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: result.items.length,
itemBuilder: (context, index) => _buildConnectionTile(
context,
result.items[index],
),
);
}
Widget _buildConnectionTile(BuildContext context, ConnectionTypeRecord item) {
return InkWell(
onTap: () {
final callback = widget.onCallback;
if (callback != null) {
callback(item);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Colors.grey[200]!,
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getIcon(item.icon),
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Icon(
Icons.chevron_right,
color: Colors.grey[400],
size: 20,
),
],
),
),
),
),
);
}
Widget _buildError(Object error) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Unable to load connection types',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
const SizedBox(height: 8),
Text(
'Please try again later',
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
IconData getIcon(String input) {
switch (input) {
case "drive_file_move":
return Icons.drive_file_move;
case "sensors_rounded":
return Icons.sensors_rounded;
case "telegram":
return Icons.telegram;
case "video":
return Icons.stream;
default:
return Icons.ac_unit;
}
}
}

View file

@ -1,170 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/connection/services/stremio_service.dart';
import 'package:madari_client/features/settings/types/connection.dart';
import '../../../engine/library.dart';
import '../services/base_connection_service.dart';
class FolderSelector extends StatefulWidget {
final void Function(List<FolderItem>) onFolderSelected;
final Connection item;
const FolderSelector({
super.key,
required this.onFolderSelected,
required this.item,
});
@override
createState() => _FolderSelectorState();
}
class _FolderSelectorState extends State<FolderSelector> {
List<FolderItem> _folders = [];
final List<FolderItem> _selectedFolder = [];
bool _isLoading = true;
String? _errorMessage;
late final BaseConnectionService connectionService;
final TextEditingController _searchController = TextEditingController();
List<FolderItem> _filteredFolders = [];
@override
void initState() {
super.initState();
final connectionId = Future.delayed(
Duration.zero,
() => widget.item.id,
);
switch (widget.item.type) {
case "stremio_addons":
connectionService = StremioService(
connectionId: connectionId,
config: widget.item.config ?? "{}",
);
default:
throw TypeError();
}
_loadFolders();
_searchController.addListener(_filterFolders);
}
void _filterFolders() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredFolders = _folders.where((folder) {
return folder.title.toLowerCase().contains(query);
}).toList();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadFolders() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final folders = await _fetchFolders();
setState(() {
_folders = folders;
_filteredFolders = _folders;
_isLoading = false;
});
_filterFolders();
} catch (e) {
setState(() {
_errorMessage = 'Failed to load folders';
_isLoading = false;
});
rethrow;
}
}
Future<List<FolderItem>> _fetchFolders() async {
return connectionService.getFolders();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search folders...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_errorMessage != null)
Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
)
else if (_filteredFolders.isEmpty)
const Text('No folders available')
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _filteredFolders.length,
itemBuilder: (context, index) {
final folder = _filteredFolders[index];
final selected =
_selectedFolder.where((i) => i.id == folder.id).isNotEmpty;
return ListTile(
title: Text(folder.title),
leading: folder.icon,
trailing: selected
? Icon(
Icons.check,
color: Theme.of(context).primaryColorLight,
)
: const Icon(Icons.circle_outlined),
selected: selected,
selectedTileColor: Colors.blue.withOpacity(0.1),
onTap: () {
setState(() {
if (!selected) {
_selectedFolder.add(folder);
} else {
_selectedFolder.remove(folder);
}
widget.onFolderSelected(_selectedFolder);
});
},
);
},
),
],
);
}
}

View file

@ -1,94 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/settings/types/connection.dart';
import '../../../engine/connection_type.dart';
import 'configure_stremio_connection.dart';
class ShowHandleConnectionType extends StatelessWidget {
final ConnectionTypeRecord item;
final void Function(Connection id) onFinish;
const ShowHandleConnectionType({
super.key,
required this.item,
required this.onFinish,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
constraints: BoxConstraints(
// Remove fixed height constraints to allow content to resize
maxHeight: MediaQuery.of(context).size.height * .9,
minWidth: double.infinity,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
child: SafeArea(
child: SingleChildScrollView(
child: _build(context),
),
),
);
}
_build(BuildContext context) {
Widget child = Container();
switch (item.type) {
case "stremio_addons":
child = StremioAddonConnection(
item: item,
onConnectionComplete: (id) {
onFinish(id);
},
);
break;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetHandle(),
_buildHeader(context),
child,
],
);
}
Widget _buildBottomSheetHandle() {
return Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Column(
children: [
Text(
"Configure connection",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
],
),
);
}
}

View file

@ -1,37 +0,0 @@
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/library.dart';
abstract class BaseConnectionService {
Future<List<FolderItem>> getFolders();
Future<ResultList<LibraryItemList>> getList({
int page = 1,
required String config,
List<LibraryItemList>? lastItem,
required List<String> type,
String? search,
});
abstract Future<String> connectionId;
Future<void> createLibrary({
required String title,
required String icon,
required List<String> types,
required String config,
}) async {
AppEngine.engine.pb.collection("library").create(
body: {
"title": title,
"icon": icon,
"types": types,
"user": AppEngine.engine.pb.authStore.record?.id,
"config": config,
"connection": connectionId,
},
);
}
Stream<List<DocSource>> getItem(LibraryItemList item);
}

View file

@ -1,266 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
import 'package:madari_client/engine/library.dart';
import 'package:madari_client/features/connection/services/base_connection_service.dart';
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
part 'stremio_service.g.dart';
class StremioService extends BaseConnectionService {
@override
Future<String> connectionId;
String config;
static final Map<String, StremioManifest> _cache = {};
late StremioConfig configParsed;
StremioService({
required this.connectionId,
required this.config,
}) {
configParsed = StremioConfig.fromJson(jsonDecode(config));
connectionId.then((item) {
AppEngine.engine.pb.collection("connection").getOne(item).then((docs) {
configParsed = StremioConfig.fromJson(docs.get("config"));
});
});
}
Future<StremioManifest> getManifest(String url) async {
if (_cache.containsKey(url)) {
return _cache[url]!;
}
final result = await http.get(Uri.parse(url));
final resultFinal = StremioManifest.fromJson(jsonDecode(result.body));
_cache[url] = resultFinal;
return resultFinal;
}
Future<Meta?> getItemMetaById(String type, String id) async {
for (final addon in configParsed.addons) {
final manifest = await getManifest(addon);
if (manifest.resources?.contains("meta") != true) {
if (kDebugMode) print("ignoring because meta is not there");
continue;
}
final ids = manifest.idPrefixes
?.firstWhere((item) => id.startsWith(item), orElse: () => "");
if (ids == null) {
continue;
}
final result = await http.get(
Uri.parse("${_getAddonBaseURL(addon)}/meta/$type/$id.json"),
);
return StreamMetaResponse.fromJson(jsonDecode(result.body)).meta;
}
return null;
}
@override
Future<List<FolderItem>> getFolders() async {
final List<FolderItem> result = [];
for (final addon in configParsed.addons) {
final manifest = await getManifest(addon);
final List<String> resources = (manifest.resources ?? []).map(
(item) {
return item.name;
},
).toList();
if (resources.contains("catalog") ||
manifest.catalogs?.isNotEmpty == true) {
for (final item
in (manifest.catalogs ?? [] as List<StremioManifestCatalog>)) {
result.add(
FolderItem(
title: item.name == null
? "${manifest.name} - ${item.type.capitalize()}".trim()
: "${item.type.capitalize()} - ${item.name}",
id: "${item.type}-${item.id}",
icon: const Icon(Icons.movie),
config: jsonEncode(
{
"type": item.type,
"id": "${item.type}-${item.id}",
"title":
"${item.type} ${item.name?.trim() != "" ? item.name : ""}"
.trim(),
'addon': addon,
'item': item,
},
),
),
);
}
}
}
return result;
}
@override
Stream<List<DocSource>> getItem(LibraryItemList item) {
throw UnimplementedError();
}
@override
Future<ResultList<LibraryItemList>> getList({
int page = 1,
required String config,
List<LibraryItemList>? lastItem,
required List<String> type,
String? search,
}) async {
final configOutput = jsonDecode(config);
final List<InternalManifestItemConfig> items = [];
for (final item in configOutput) {
final itemToPush = InternalManifestItemConfig.fromJson(item);
items.add(itemToPush);
}
final result = ResultList<LibraryItemList>();
result.page = page;
result.perPage = 50;
result.items = List<LibraryItemList>.empty(growable: true);
for (final item in items) {
String url =
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}.json";
if (page != 1) {
final skip = result.perPage * (page - 1);
url =
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/skip=${Uri.encodeComponent(skip.toString())}.json";
}
if ((search ?? "").isNotEmpty) {
url =
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/search=${Uri.encodeComponent(search!)}.json";
}
final httpBody = await http.get(
Uri.parse(
url,
),
);
final meta = StrmioMeta.fromJson(json.decode(httpBody.body));
for (final meta in meta.metas ?? []) {
result.items.add(
LibraryItemList(
id: meta.id,
title: meta.name!,
logo: meta.poster,
extra: meta.description,
config: jsonEncode(meta),
popularity: (meta.popularity ?? 0),
),
);
}
}
return result;
}
_getAddonBaseURL(String input) {
return input.endsWith("/manifest.json")
? input.replaceAll("/manifest.json", "")
: input;
}
Stream<List<VideoStream>> getStreams(
String type,
String id, {
String? season,
String? episode,
}) async* {
final List<VideoStream> streams = [];
for (final addon in configParsed.addons) {
final addonManifest = await getManifest(addon);
for (final resource in (addonManifest.resources ?? [])) {
if ((resource is String && resource == "stream") ||
((resource is ResourceObject) &&
resource.types?.contains(type) == true)) {
final url =
"${_getAddonBaseURL(addon)}/stream/$type/${Uri.encodeComponent(id)}.json";
final result = await http.get(Uri.parse(url), headers: {});
final body = StreamResponse.fromJson(jsonDecode(result.body));
streams.addAll(body.streams);
yield streams;
}
}
}
return;
}
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
}
}
@JsonSerializable()
class InternalManifestItemConfig {
final InternalItem item;
final String addon;
InternalManifestItemConfig({
required this.item,
required this.addon,
});
factory InternalManifestItemConfig.fromJson(Map<String, dynamic> json) =>
_$InternalManifestItemConfigFromJson(json);
Map<String, dynamic> toJson() => _$InternalManifestItemConfigToJson(this);
}
@JsonSerializable()
class InternalItem {
final String id;
final String? name;
final String type;
InternalItem({
required this.id,
this.name,
required this.type,
});
factory InternalItem.fromJson(Map<String, dynamic> json) =>
_$InternalItemFromJson(json);
Map<String, dynamic> toJson() => _$InternalItemToJson(this);
}

View file

@ -1 +0,0 @@
export '../../connections/types/stremio/stremio_base.types.dart';

View file

@ -1,271 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/engine/connection_type.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/connections/service/stremio_connection_service.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../settings/types/connection.dart';
import '../types/base/base.dart';
import '../widget/stremio/stremio_create.dart';
abstract class BaseConnectionService {
Widget renderCard(LibraryItem item, String heroPrefix);
Widget renderList(LibraryItem item, String heroPrefix);
static final Map<String, RecordModel> _item = {};
final String connectionId;
static Future<LibraryRecordResponse> getLibraries() async {
final library =
await AppEngine.engine.pb.collection("library").getFullList();
return LibraryRecordResponse(
data: library
.map(
(item) => LibraryRecord.fromRecord(item),
)
.toList(),
);
}
factory BaseConnectionService.create(
Connection item,
ConnectionTypeRecord type,
) {
switch (type.type) {
case "stremio_addons":
return StremioConnectionService(
connectionId: item.id,
config: StremioConfig.fromJson(item.config),
);
}
throw ErrorDescription("Connection is not supported");
}
static Future<ConnectionResponse> connectionByIdRaw(
String connectionId,
) async {
RecordModel model_;
if (_item.containsKey(connectionId)) {
model_ = _item[connectionId]!;
} else {
model_ = await AppEngine.engine.pb
.collection("connection")
.getOne(connectionId, expand: "type");
_item[connectionId] = model_;
}
return ConnectionResponse(
connection: Connection.fromRecord(model_),
connectionTypeRecord: ConnectionTypeRecord.fromRecord(
model_.get<RecordModel>("expand.type"),
),
);
}
static BaseConnectionService connectionById(
ConnectionResponse connection,
) {
return BaseConnectionService.create(
connection.connection,
connection.connectionTypeRecord,
);
}
static Widget createTypeWidget(String type, OnSuccessCallback onSuccess) {
switch (type) {
case "stremio":
return const StremioCreateConnection();
}
throw ErrorDescription("Connection is not supported");
}
Future<PaginatedResult<LibraryItem>> getItems(
LibraryRecord library, {
List<ConnectionFilterItem>? items,
int? page,
int? perPage,
String? cursor,
});
Future<List<LibraryItem>> getBulkItem(
List<LibraryItem> ids,
);
Future<List<ConnectionFilter<T>>> getFilters<T>(
LibraryRecord library,
);
Future<LibraryItem?> getItemById(LibraryItem id);
Future<void> getStreams(
LibraryItem id, {
OnStreamCallback? callback,
});
BaseConnectionService({
required this.connectionId,
});
}
class StreamList {
final String title;
final String? description;
final DocSource source;
final StreamSource? streamSource;
StreamList({
required this.title,
this.description,
required this.source,
this.streamSource,
});
}
class StreamSource {
final String title;
final String id;
StreamSource({
required this.title,
required this.id,
});
}
class ConnectionResponse {
final Connection connection;
final ConnectionTypeRecord connectionTypeRecord;
ConnectionResponse({
required this.connectionTypeRecord,
required this.connection,
});
Map<String, dynamic> toJson() {
return {
"connection": connection,
"connectionTypeRecord": connectionTypeRecord,
};
}
}
typedef OnSuccessCallback = void Function(String connectionId);
class LibraryRecordResponse extends Jsonable {
final List<LibraryRecord> data;
LibraryRecordResponse({
required this.data,
});
@override
Map<String, dynamic> toJson() {
return {
"data": data.map((item) => item.toJson()).toList(),
};
}
}
class ConnectionFilter<T> {
final String title;
final ConnectionFilterType type;
final List<T>? values;
ConnectionFilter({
required this.title,
required this.type,
this.values,
});
}
enum ConnectionFilterType {
text,
options,
}
class ConnectionFilterItem {
final String title;
final dynamic value;
ConnectionFilterItem({
required this.title,
required this.value,
});
}
abstract class LibraryItem extends Jsonable {
late final String id;
LibraryItem({
required this.id,
});
@override
Map<String, dynamic> toJson();
}
abstract class PaginatedResult<T extends LibraryItem> {
List<T> get items;
bool get hasMore;
Map<String, dynamic> toJson() {
return {
"items": items.map((res) => res.toJson()),
"hasMore": hasMore,
};
}
}
class CursorPaginatedResult<T extends LibraryItem>
implements PaginatedResult<T> {
@override
final List<T> items;
@override
final bool hasMore;
final String? nextCursor;
CursorPaginatedResult({
required this.items,
required this.hasMore,
this.nextCursor,
});
Map<String, dynamic> toJson() {
return {
"items": items.map((res) => res.toJson()),
"hasMore": hasMore,
"nextCursor": nextCursor,
};
}
}
class PagePaginatedResult implements PaginatedResult {
@override
final List<LibraryItem> items;
@override
final bool hasMore;
final int totalPages;
final int currentPage;
PagePaginatedResult({
required this.items,
required this.hasMore,
required this.totalPages,
required this.currentPage,
});
@override
Map<String, dynamic> toJson() {
return {
"items": items.map((res) => res.toJson()).toList(),
"hasMore": hasMore,
"totalPages": totalPages,
"currentPage": currentPage,
};
}
}

View file

@ -1,700 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:cached_query/cached_query.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/features/connections/types/base/base.dart';
import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart';
import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:madari_client/utils/common.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../connection/services/stremio_service.dart';
import '../types/stremio/stremio_base.types.dart';
import './base_connection_service.dart';
part 'stremio_connection_service.g.dart';
final Map<String, String> manifestCache = {};
typedef OnStreamCallback = void Function(List<StreamList>? items, Error?);
class StremioConnectionService extends BaseConnectionService {
final StremioConfig config;
final Logger _logger = Logger('StremioConnectionService');
StremioConnectionService({
required super.connectionId,
required this.config,
}) {
_logger.info('StremioConnectionService initialized with config: $config');
}
@override
Future<LibraryItem?> getItemById(LibraryItem id) async {
_logger.fine('Fetching item by ID: ${id.id}');
return Query<LibraryItem?>(
key: "meta_${id.id}",
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: (id as Meta).type == "movie"
? const Duration(days: 30)
: const Duration(days: 1),
),
queryFn: () async {
for (final addon in config.addons) {
_logger.finer('Checking addon: $addon');
final manifest = await _getManifest(addon);
if (manifest.resources == null) {
_logger.finer('No resources found in manifest for addon: $addon');
continue;
}
List<String> idPrefixes = [];
bool isMeta = false;
for (final item in manifest.resources!) {
if (item.name == "meta") {
idPrefixes
.addAll((item.idPrefix ?? []) + (item.idPrefixes ?? []));
isMeta = true;
break;
}
}
if (!isMeta) {
_logger
.finer('No meta resource found in manifest for addon: $addon');
continue;
}
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
.firstWhere((item) => id.id.startsWith(item), orElse: () => "");
if (ids.isEmpty) {
_logger.finer('No matching ID prefix found for addon: $addon');
continue;
}
final result = await http.get(
Uri.parse(
"${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json"),
);
final item = jsonDecode(result.body);
if (item['meta'] == null) {
_logger.finer(
'No meta data found for item: ${id.id} in addon: $addon');
return null;
}
return StreamMetaResponse.fromJson(item).meta;
}
_logger.warning('No meta data found for item: ${id.id} in any addon');
return null;
},
)
.stream
.where((item) => item.status != QueryStatus.loading)
.first
.then((docs) {
if (docs.error != null) {
_logger.severe('Error fetching item by ID: ${docs.error}');
throw docs.error!;
}
return docs.data;
});
}
List<InternalManifestItemConfig> getConfig(dynamic configOutput) {
_logger.fine('Parsing config output');
final List<InternalManifestItemConfig> configItems = [];
for (final item in configOutput) {
final itemToPush = InternalManifestItemConfig.fromJson(
jsonDecode(item),
);
configItems.add(itemToPush);
}
_logger.finer('Config parsed successfully: $configItems');
return configItems;
}
Stream<List<Subtitle>> getSubtitles(Meta record) async* {
final List<Subtitle> subtitles = [];
_logger.info('getting subtitles');
for (final addon in config.addons) {
final manifest = await _getManifest(addon);
final resource = manifest.resources
?.firstWhereOrNull((res) => res.name == "subtitles");
if (resource == null) {
continue;
}
final types = resource.types ?? manifest.types ?? [];
final idPrefixes =
resource.idPrefixes ?? resource.idPrefix ?? manifest.idPrefixes;
if (!types.contains(record.type)) {
continue;
}
final hasPrefixMatch = idPrefixes?.firstWhereOrNull((item) {
return record.id.startsWith(item);
});
if (hasPrefixMatch == null) {
continue;
}
final addonBase = _getAddonBaseURL(addon);
final url =
"$addonBase/subtitles/${record.type}/${Uri.encodeQueryComponent(record.currentVideo?.id ?? record.id)}.json";
_logger.info('loading subtitles from $url');
final body = await http.get(Uri.parse(url));
if (body.statusCode != 200) {
_logger.warning('failed due to status code ${body.statusCode}');
continue;
}
final dataBody = jsonDecode(body.body);
try {
final responses = SubtitleResponse.fromJson(dataBody);
subtitles.addAll(responses.subtitles);
yield subtitles;
} catch (e) {
_logger.warning("failed to parse subtitle response");
}
}
}
@override
Future<PaginatedResult<LibraryItem>> getItems(
LibraryRecord library, {
List<ConnectionFilterItem>? items,
int? page,
int? perPage,
String? cursor,
}) async {
_logger.fine('Fetching items for library: ${library.id}');
final List<Meta> returnValue = [];
final configItems = getConfig(library.config);
bool hasMore = false;
const perPage = 50;
items = [...(items ?? [])];
if (page != null) {
items.add(
ConnectionFilterItem(
title: "skip",
value: page * perPage,
),
);
}
for (final item in configItems) {
String url =
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}";
if (items.isNotEmpty) {
String filterPath = items.map((filter) {
return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}";
}).join('&');
if (filterPath.isNotEmpty) {
url += "/$filterPath";
}
}
url += ".json";
final result = await Query(
config: QueryConfig(
cacheDuration: const Duration(hours: 8),
),
queryFn: () async {
try {
_logger.finer('Fetching catalog from URL: $url');
final httpBody = await http.get(Uri.parse(url));
return StrmioMeta.fromJson(
jsonDecode(httpBody.body),
);
} catch (e, stack) {
_logger.severe('Error parsing catalog $url', e, stack);
rethrow;
}
},
key: url,
)
.stream
.where((item) => item.status != QueryStatus.loading)
.first
.then((docs) {
if (docs.error != null) {
_logger.severe('Error fetching catalog', docs.error);
throw docs.error!;
}
return docs.data!;
});
hasMore = result.hasMore ?? false;
returnValue.addAll(result.metas ?? []);
}
_logger.finer('Items fetched successfully: ${returnValue.length} items');
return PagePaginatedResult(
items: returnValue.toList(),
currentPage: page ?? 1,
totalPages: 0,
hasMore: hasMore,
);
}
@override
Widget renderCard(LibraryItem item, String heroPrefix) {
_logger.fine('Rendering card for item: ${item.id}');
return StremioCard(
item: item,
prefix: heroPrefix,
connectionId: connectionId,
service: this,
);
}
@override
Future<List<LibraryItem>> getBulkItem(List<LibraryItem> ids) async {
_logger.fine('Fetching bulk items: ${ids.length} items');
if (ids.isEmpty) {
_logger.finer('No items to fetch');
return [];
}
return (await Future.wait(
ids.map(
(res) async {
return getItemById(res).then((item) {
if (item == null) {
_logger.finer('Item not found: ${res.id}');
return null;
}
return (item as Meta).copyWith(
progress: (res as Meta).progress,
selectedVideoIndex: res.selectedVideoIndex,
);
}).catchError((err, stack) {
_logger.severe('Error fetching item: ${res.id}', err, stack);
return (res as Meta);
});
},
),
))
.whereType<Meta>()
.toList();
}
@override
Widget renderList(LibraryItem item, String heroPrefix) {
_logger.fine('Rendering list item: ${item.id}');
return StremioListItem(item: item);
}
Future<StremioManifest> _getManifest(String url) async {
_logger.fine('Fetching manifest from URL: $url');
return Query(
key: url,
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: const Duration(days: 1),
),
queryFn: () async {
final String result;
if (manifestCache.containsKey(url)) {
_logger.finer('Manifest found in cache for URL: $url');
result = manifestCache[url]!;
} else {
_logger.finer('Fetching manifest from network for URL: $url');
result = (await http.get(Uri.parse(url))).body;
manifestCache[url] = result;
}
final body = jsonDecode(result);
final resultFinal = StremioManifest.fromJson(body);
_logger.finer('Manifest successfully parsed for URL: $url');
return resultFinal;
},
)
.stream
.where((item) => item.status != QueryStatus.loading)
.first
.then((docs) {
if (docs.error != null) {
_logger.severe('Error fetching manifest: ${docs.error}');
throw docs.error!;
}
return docs.data!;
});
}
String _getAddonBaseURL(String input) {
return input.endsWith("/manifest.json")
? input.replaceAll("/manifest.json", "")
: input;
}
@override
Future<List<ConnectionFilter<T>>> getFilters<T>(LibraryRecord library) async {
_logger.fine('Fetching filters for library: ${library.id}');
final configItems = getConfig(library.config);
List<ConnectionFilter<T>> filters = [];
try {
for (final addon in configItems) {
final addonManifest = await _getManifest(addon.addon);
if ((addonManifest.catalogs?.isEmpty ?? true) == true) {
_logger.finer('No catalogs found for addon: ${addon.addon}');
continue;
}
final catalogs = addonManifest.catalogs!.where((item) {
return item.id == addon.item.id && item.type == addon.item.type;
}).toList();
for (final catalog in catalogs) {
if (catalog.extra == null) {
_logger.finer('No extra filters found for catalog: ${catalog.id}');
continue;
}
for (final extraItem in catalog.extra!) {
if (extraItem.options == null ||
extraItem.options?.isEmpty == true) {
filters.add(
ConnectionFilter<T>(
title: extraItem.name,
type: ConnectionFilterType.text,
),
);
} else {
filters.add(
ConnectionFilter<T>(
title: extraItem.name,
type: ConnectionFilterType.options,
values: extraItem.options?.whereType<T>().toList(),
),
);
}
}
}
}
} catch (e) {
_logger.severe('Error fetching filters', e);
}
_logger.finer('Filters fetched successfully: $filters');
return filters;
}
@override
Future<void> getStreams(
LibraryItem id, {
OnStreamCallback? callback,
}) async {
_logger.fine('Fetching streams for item: ${id.id}');
final List<StreamList> streams = [];
final meta = id as Meta;
final List<Future<void>> promises = [];
for (final addon in config.addons) {
final future = Future.delayed(const Duration(seconds: 0), () async {
final addonManifest = await _getManifest(addon);
for (final resource_ in (addonManifest.resources ?? [])) {
final resource = resource_ as ResourceObject;
if (!doesAddonSupportStream(resource, addonManifest, meta)) {
_logger.finer(
'Addon does not support stream: ${addonManifest.name}',
);
continue;
}
final url =
"${_getAddonBaseURL(addon)}/stream/${meta.type}/${Uri.encodeComponent(id.currentVideo?.id ?? id.id)}.json";
final result = await Query(
key: url,
queryFn: () async {
final result = await http.get(Uri.parse(url), headers: {});
if (result.statusCode == 404) {
_logger.warning(
'Invalid status code for addon: ${addonManifest.name}',
);
if (callback != null) {
callback(
null,
ArgumentError(
"Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}",
),
);
}
}
return result.body;
},
)
.stream
.where((item) => item.status != QueryStatus.loading)
.first
.then((docs) {
return docs.data;
});
if (result == null) {
_logger.finer('No stream data found for URL: $url');
continue;
}
final body = StreamResponse.fromJson(jsonDecode(result));
streams.addAll(
body.streams
.map(
(item) => videoStreamToStreamList(
item,
meta,
addonManifest,
),
)
.whereType<StreamList>()
.toList(),
);
if (callback != null) {
callback(streams, null);
}
}
}).catchError((error, stacktrace) {
_logger.severe('Error fetching streams', error, stacktrace);
if (callback != null) callback(null, error);
});
promises.add(future);
}
await Future.wait(promises);
_logger.finer('Streams fetched successfully: ${streams.length} streams');
return;
}
bool doesAddonSupportStream(
ResourceObject resource,
StremioManifest addonManifest,
Meta meta,
) {
if (resource.name != "stream") {
_logger.finer('Resource is not a stream: ${resource.name}');
return false;
}
final idPrefixes =
resource.idPrefixes ?? addonManifest.idPrefixes ?? resource.idPrefix;
final types = resource.types ?? addonManifest.types;
if (types == null || !types.contains(meta.type)) {
_logger.finer('Addon does not support type: ${meta.type}');
return false;
}
if ((idPrefixes ?? []).isEmpty == true) {
_logger.finer('No ID prefixes found, assuming support');
return true;
}
final hasIdPrefix = (idPrefixes ?? []).where(
(item) => meta.id.startsWith(item),
);
if (hasIdPrefix.isEmpty) {
_logger.finer('No matching ID prefix found');
return false;
}
_logger.finer('Addon supports stream');
return true;
}
StreamList? videoStreamToStreamList(
VideoStream item,
Meta meta,
StremioManifest addonManifest,
) {
String streamTitle = (item.name != null
? "${(item.name ?? "")} ${(item.title ?? "")}"
: item.title) ??
"No title";
try {
streamTitle = utf8.decode(streamTitle.runes.toList());
} catch (e) {}
String? streamDescription = item.description;
try {
streamDescription = item.description != null
? utf8.decode((item.description!).runes.toList())
: null;
} catch (e) {}
String title = meta.name ?? item.title ?? "No title";
DocSource? source;
if (item.url != null) {
source = MediaURLSource(
title: title,
url: item.url!,
id: meta.id,
);
}
if (item.infoHash != null) {
source = TorrentSource(
title: title,
infoHash: item.infoHash!,
id: meta.id,
fileName: "$title.mp4",
);
}
if (source == null) {
_logger.finer('No valid source found for stream');
return null;
}
String addonName = addonManifest.name;
try {
addonName = utf8.decode((addonName).runes.toList());
} catch (e) {
_logger.warning('Failed to decode addon name', e);
}
_logger.finer('Stream list created successfully');
return StreamList(
title: streamTitle,
description: streamDescription,
source: source,
streamSource: StreamSource(
title: addonName,
id: addonManifest.id,
),
);
}
}
@JsonSerializable()
class StremioConfig {
List<String> addons;
StremioConfig({
required this.addons,
});
factory StremioConfig.fromRecord(RecordModel record) =>
StremioConfig.fromJson(record.toJson());
factory StremioConfig.fromJson(Map<String, dynamic> json) =>
_$StremioConfigFromJson(json);
Map<String, dynamic> toJson() => _$StremioConfigToJson(this);
}
class Subtitle {
final String id;
final String url;
final String? subEncoding;
final String? lang;
final String? m;
final String? g; // Making g optional since some entries have empty string
const Subtitle({
required this.id,
required this.url,
required this.subEncoding,
required this.lang,
required this.m,
this.g,
});
factory Subtitle.fromJson(Map<String, dynamic> json) {
return Subtitle(
id: json['id'] as String,
url: json['url'] as String,
subEncoding: json['SubEncoding'] as String?,
lang: json['lang'] as String?,
m: json['m'] as String?,
g: json['g'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'url': url,
'SubEncoding': subEncoding,
'lang': lang,
'm': m,
'g': g,
};
}
}
class SubtitleResponse {
final List<Subtitle> subtitles;
final int? cacheMaxAge;
const SubtitleResponse({
required this.subtitles,
required this.cacheMaxAge,
});
factory SubtitleResponse.fromJson(Map<String, dynamic> json) {
return SubtitleResponse(
subtitles: (json['subtitles'] as List)
.map((e) => Subtitle.fromJson(e as Map<String, dynamic>))
.toList(),
cacheMaxAge: json['cacheMaxAge'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'subtitles': subtitles.map((e) => e.toJson()).toList(),
'cacheMaxAge': cacheMaxAge,
};
}
}

View file

@ -1,34 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pocketbase/pocketbase.dart';
part 'base.g.dart';
@JsonSerializable()
class LibraryRecord extends Jsonable {
final String id;
final String icon;
final String title;
final List<String> types;
final dynamic config;
final String connection;
final String connectionType;
LibraryRecord({
required this.id,
required this.icon,
required this.title,
required this.types,
required this.config,
required this.connection,
required this.connectionType,
});
factory LibraryRecord.fromRecord(RecordModel record) =>
LibraryRecord.fromJson(record.toJson());
factory LibraryRecord.fromJson(Map<String, dynamic> json) =>
_$LibraryRecordFromJson(json);
@override
Map<String, dynamic> toJson() => _$LibraryRecordToJson(this);
}

View file

@ -1,609 +0,0 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/types/base/base.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../utils/grid.dart';
import '../stremio/stremio_filter.dart';
final pb = AppEngine.engine.pb;
class RenderLibraryList extends StatefulWidget {
final LibraryRecord item;
final List<ConnectionFilterItem> filters;
final bool isGrid;
const RenderLibraryList({
super.key,
required this.item,
required this.filters,
this.isGrid = false,
});
@override
State<RenderLibraryList> createState() => _RenderLibraryListState();
}
class _RenderLibraryListState extends State<RenderLibraryList> {
late final query = Query(
key: widget.item.id,
queryFn: () => BaseConnectionService.connectionByIdRaw(
widget.item.connection,
),
);
@override
Widget build(BuildContext context) {
return QueryBuilder(
query: query,
builder: (ctx, state) {
if (state.status == QueryStatus.loading) {
return const Center(
child: SpinnerCards(),
);
}
if (state.status == QueryStatus.error) {
final errorMessage = (
state.error is ClientException
? (state.error as ClientException).response["message"]
: "",
);
return SizedBox(
height: getListHeight(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: Colors.black45,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: Center(
child: Text(
"Something went wrong while loading library\n${errorMessage.$1}",
textAlign: TextAlign.center,
style: GoogleFonts.exo2().copyWith(
fontSize: 16,
),
),
),
),
),
),
);
}
try {
return _RenderLibraryList(
item: widget.item,
service: state.data!,
filters: widget.filters,
isGrid: widget.isGrid,
);
} catch (e) {
return Text("Error $e");
}
},
);
}
}
class _RenderLibraryList extends StatefulWidget {
final LibraryRecord item;
final ConnectionResponse service;
final List<ConnectionFilterItem> filters;
final bool isGrid;
const _RenderLibraryList({
required this.item,
required this.service,
required this.filters,
required this.isGrid,
});
@override
State<_RenderLibraryList> createState() => __RenderLibraryListState();
}
class __RenderLibraryListState extends State<_RenderLibraryList> {
late BaseConnectionService service = BaseConnectionService.connectionById(
widget.service,
);
final _scrollController = ScrollController();
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
void _onScroll() {
if (_isBottom && query.state.status != QueryStatus.loading) {
query.getNextPage();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
query = getQuery();
}
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
loadFilters();
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
List<ConnectionFilterItem> filters = [];
InfiniteQuery getQuery() {
return InfiniteQuery<List<LibraryItem>, int>(
key:
"loadLibrary${widget.item.id}${(widget.filters + filters).map((res) => "${res.title}=${res.value}").join("&")}",
queryFn: (page) {
return service
.getItems(
widget.item,
items: widget.filters + filters,
page: page,
)
.then((docs) {
return docs.items.toList();
}).catchError((e, stack) {
throw e;
});
},
getNextArg: (state) {
if (state.lastPage?.isEmpty ?? false) return null;
return state.length;
},
);
}
late InfiniteQuery query = getQuery();
bool isUnsupported = false;
loadFilters() async {
final filters = await service.getFilters(widget.item);
if (mounted) {
setState(() {
filterList = filters;
});
}
}
List<ConnectionFilter>? filterList;
@override
Widget build(BuildContext context) {
if (widget.isGrid) {
return Scaffold(
appBar: AppBar(
title: Text(widget.item.title),
),
body: SizedBox(
height: MediaQuery.of(context).size.height - 96,
child: Flex(
direction: Axis.vertical,
children: [
const SizedBox(
height: 10,
),
if (filterList == null)
Row(
children: [
SizedBox(
height: 36,
width: 120,
child: Padding(
padding: const EdgeInsets.only(
left: 10.0,
right: 10.0,
),
child: Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(20),
),
child: const SizedBox(
height: 36,
width: 120,
),
),
),
),
],
),
if (filterList != null)
InlineFilters(
filters: filterList ?? [],
filterCallback: (item) {
filters = item;
setState(() {
query = getQuery();
});
},
),
const SizedBox(
height: 10,
),
Expanded(
child: SizedBox(
height: MediaQuery.of(context).size.height - 96,
child: Padding(
padding: const EdgeInsets.only(
left: 10.0,
right: 10.0,
),
child: _buildBody(),
),
),
),
],
),
),
);
}
return _buildBody();
}
_buildBody() {
final listHeight = getListHeight(context);
if (isUnsupported) {
return SizedBox(
height: listHeight,
child: const Text("This connection is not supported "),
);
}
return SizedBox(
height: listHeight,
child: InfiniteQueryBuilder(
query: query,
builder: (context, data, query) {
final items = (data.data?.expand((e) => e).toList() ?? [])
.whereType<LibraryItem>()
.toList();
return RenderListItems(
hasError: data.status == QueryStatus.error,
onRefresh: () {
query.refetch();
},
loadMore: () {
query.getNextPage();
},
itemScrollController: _scrollController,
isLoadingMore: data.status == QueryStatus.loading && items.isEmpty,
isGrid: widget.isGrid,
items: items,
heroPrefix: widget.item.id,
service: service,
);
},
),
);
}
}
typedef OnContextTap = void Function(
String actionId,
LibraryItem item,
);
class ContextMenuItem {
final String id;
final String title;
final bool isDefaultAction;
final bool isDestructiveAction;
final IconData? icon;
final OnContextTap? onCallback;
ContextMenuItem({
required this.title,
this.isDefaultAction = false,
this.isDestructiveAction = false,
this.icon,
required this.id,
this.onCallback,
});
}
class RenderListItems extends StatefulWidget {
final ScrollController? controller;
final ScrollController? itemScrollController;
final bool isGrid;
final bool hasError;
final VoidCallback? onRefresh;
final BaseConnectionService service;
final List<LibraryItem> items;
final String heroPrefix;
final dynamic error;
final bool isWide;
final bool isLoadingMore;
final VoidCallback? loadMore;
final List<ContextMenuItem> contextMenuItems;
final OnContextTap? onContextMenu;
const RenderListItems({
super.key,
this.controller,
this.isGrid = false,
this.hasError = false,
this.onRefresh,
required this.items,
required this.service,
required this.heroPrefix,
this.itemScrollController,
this.error,
this.isWide = false,
this.isLoadingMore = false,
this.loadMore,
this.contextMenuItems = const [],
this.onContextMenu,
});
@override
State<RenderListItems> createState() => _RenderListItemsState();
}
class _RenderListItemsState extends State<RenderListItems> {
@override
Widget build(BuildContext context) {
final listHeight = getListHeight(context);
final itemWidth = getItemWidth(
context,
isWide: widget.isWide,
);
return CustomScrollView(
controller: widget.controller,
physics: widget.isGrid
? const AlwaysScrollableScrollPhysics()
: const NeverScrollableScrollPhysics(),
slivers: [
if (widget.hasError)
SliverToBoxAdapter(
child: SizedBox(
height: listHeight,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Something went wrong while loading the library \n${widget.error}",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 10,
),
TextButton.icon(
label: const Text("Retry"),
onPressed: widget.onRefresh,
icon: const Icon(
Icons.refresh,
),
)
],
),
),
),
),
),
if (widget.isGrid) ...[
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: getGridResponsiveColumnCount(context),
mainAxisSpacing: getGridResponsiveSpacing(context),
crossAxisSpacing: getGridResponsiveSpacing(context),
childAspectRatio: 2 / 3,
),
itemCount: widget.items.length,
itemBuilder: (ctx, index) {
final item = widget.items[index];
return widget.service.renderCard(
item,
"${index}_${widget.heroPrefix}",
);
},
),
if (widget.isLoadingMore)
SliverPadding(
padding: const EdgeInsets.only(
top: 8.0,
right: 8.0,
),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: getGridResponsiveColumnCount(context),
mainAxisSpacing: getGridResponsiveSpacing(context),
crossAxisSpacing: getGridResponsiveSpacing(context),
childAspectRatio: 2 / 3,
),
delegate: SliverChildBuilderDelegate(
(ctx, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Shimmer.fromColors(
baseColor: Colors.grey[800]!,
highlightColor: Colors.grey[700]!,
child: Container(
color: Colors.grey[800],
),
),
);
},
childCount: 4, // Fixed number of loading items
),
),
),
] else ...[
if (!widget.isLoadingMore)
SliverToBoxAdapter(
child: CupertinoPageScaffold(
resizeToAvoidBottomInset: true,
child: SizedBox(
height: listHeight,
child: ListView.builder(
controller: widget.itemScrollController,
itemBuilder: (ctx, index) {
final item = widget.items[index];
if (widget.contextMenuItems.isEmpty) {
return SizedBox(
width: itemWidth,
child: Container(
child: widget.service.renderCard(
item,
"${index}_${widget.heroPrefix}",
),
),
);
}
return CupertinoContextMenu(
enableHapticFeedback: true,
actions: widget.contextMenuItems.map((menu) {
return CupertinoContextMenuAction(
isDefaultAction: menu.isDefaultAction,
isDestructiveAction: menu.isDestructiveAction,
trailingIcon: menu.icon,
onPressed: () {
if (widget.onContextMenu != null) {
widget.onContextMenu!(
menu.id,
item,
);
}
},
child: Text(menu.title),
);
}).toList(),
child: SizedBox(
width: itemWidth,
child: Container(
constraints: BoxConstraints(
maxHeight: listHeight,
),
child: widget.service.renderCard(
item,
"${index}_${widget.heroPrefix}",
),
),
),
);
},
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
),
),
),
),
if (widget.isLoadingMore)
SliverToBoxAdapter(
child: SpinnerCards(
isWide: widget.isWide,
),
),
],
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
),
],
);
}
}
class SpinnerCards extends StatelessWidget {
final bool isWide;
const SpinnerCards({
super.key,
this.isWide = false,
});
@override
Widget build(BuildContext context) {
final itemWidth = getItemWidth(
context,
isWide: isWide,
);
final itemHeight = getListHeight(context);
return SizedBox(
height: itemHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, _) {
return SizedBox(
width: itemWidth,
child: Container(
margin: const EdgeInsets.only(
right: 8,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Shimmer.fromColors(
baseColor: Colors.grey[800]!,
highlightColor: Colors.grey[700]!,
child: Container(
color: Colors.grey[800],
),
),
),
),
);
},
itemCount: 10,
),
);
}
}
double getItemWidth(BuildContext context, {bool isWide = false}) {
double screenWidth = MediaQuery.of(context).size.width;
return screenWidth > 800
? (isWide ? 400.0 : 200.0)
: (isWide ? 280.0 : 120.0);
}
double getListHeight(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;
return screenWidth > 800 ? 300.0 : 180.0;
}

View file

@ -1,322 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart';
import '../../../../utils/external_player.dart';
import '../../../../utils/load_language.dart';
import '../../../doc_viewer/types/doc_source.dart';
import '../../../downloads/service/service.dart';
// Note: This is because there is some conflict between drift and this
const kIsWeb = bool.fromEnvironment('dart.library.js_util');
class RenderStreamList extends StatefulWidget {
final BaseConnectionService service;
final LibraryItem id;
final bool shouldPop;
final double? progress;
const RenderStreamList({
super.key,
required this.service,
required this.id,
this.progress,
required this.shouldPop,
});
@override
State<RenderStreamList> createState() => _RenderStreamListState();
}
class _RenderStreamListState extends State<RenderStreamList> {
final Map<String, double> _downloadProgress = {};
final Map<String, String> _downloadError = {};
late StreamSubscription<TaskUpdate> _hasError;
@override
void initState() {
super.initState();
getLibrary();
if (!kIsWeb) {
DownloadService.instance.getAllDownloads().then((data) {
for (var item in data) {
_downloadProgress[item.taskId] = item.progress;
if (item.exception?.description != null) {
_downloadError[item.taskId] = item.exception!.description;
}
}
if (mounted) {
setState(() {});
}
});
}
if (!kIsWeb) {
_hasError = DownloadService.instance.updates.listen((update) async {
if (update is TaskStatusUpdate) {
final task =
await DownloadService.instance.getById(update.task.taskId);
if (mounted) {
setState(() {
_downloadProgress[update.task.taskId] = task?.progress ?? 0;
if (task?.exception?.description != null) {
_downloadError[update.task.taskId] =
task!.exception!.description;
}
});
} else {
_hasError.cancel();
}
}
});
}
}
@override
void dispose() {
super.dispose();
_hasError.cancel();
}
Widget _buildDownloadButton(BuildContext context, String url, String title) {
final taskId = calculateHash(url);
final progress = _downloadProgress[taskId];
return SizedBox(
child: progress != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 2,
),
),
IconButton(
icon: _downloadError[taskId] == null
? const Icon(Icons.stop_circle)
: const Icon(Icons.delete),
onPressed: () async {
if (_downloadError[taskId] == null) {
final task =
await DownloadService.instance.getById(taskId);
await DownloadService.instance.pauseDownload(
task!.task as DownloadTask,
);
} else {
DownloadService.instance.deleteDownload(taskId);
setState(() {
_downloadProgress.remove(taskId);
});
}
},
),
],
)
: IconButton(
icon: const Icon(Icons.download),
onPressed: () async {
final task = DownloadTask(
url: url,
taskId: taskId,
filename: "${(widget.id as Meta).name!}.mp4",
);
await DownloadService.instance.startDownload(task);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Download started'),
action: SnackBarAction(
label: 'View',
onPressed: () {
Navigator.pushNamed(context, '/downloads');
},
),
),
);
},
),
);
}
bool hasError = false;
bool isLoading = true;
List<StreamList>? _list;
final List<Error> errors = [];
final Map<String, StreamSource> _sources = {};
Future getLibrary() async {
await BaseConnectionService.getLibraries();
await widget.service.getStreams(
widget.id,
callback: (items, error) {
if (mounted) {
setState(() {
isLoading = false;
_list = items;
_list?.forEach((item) {
if (item.streamSource != null) {
_sources[item.streamSource!.id] = item.streamSource!;
}
});
});
}
},
);
if (mounted) {
setState(() {
isLoading = false;
_list = _list ?? [];
});
}
}
String? selectedAddonFilter;
@override
Widget build(BuildContext context) {
if (isLoading || _list == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (hasError) {
return const Text("Something went wrong");
}
if ((_list ?? []).isEmpty) {
return Center(
child: Text(
"No stream found",
style: Theme.of(context).textTheme.bodyLarge,
),
);
}
final filteredList = (_list ?? []).where((item) {
if (item.streamSource == null || selectedAddonFilter == null) {
return true;
}
return item.streamSource!.id == selectedAddonFilter;
}).toList();
return ListView.builder(
itemBuilder: (context, index) {
if (index == 0) {
return SizedBox(
height: 42,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final value in _sources.values)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
selected: value.id == selectedAddonFilter,
label: Text(value.title),
onSelected: (i) {
setState(() {
selectedAddonFilter = i ? value.id : null;
});
},
),
),
],
),
),
);
}
final item = filteredList[index - 1];
return ListTile(
title: Text(item.title),
subtitle: item.description == null && item.streamSource == null
? null
: Text(
"${item.description ?? ""}\n---\n${item.streamSource?.title ?? ""}"
.trim(),
),
trailing: (item.source is MediaURLSource)
? _buildDownloadButton(
context,
(item.source as MediaURLSource).url,
item.title,
)
: null,
onTap: () {
if (widget.shouldPop) {
Navigator.of(context).pop(item.source);
return;
}
PlaybackConfig config = getPlaybackConfig();
if (config.externalPlayer) {
if (!kIsWeb) {
if (item.source is URLSource || item.source is TorrentSource) {
if (config.externalPlayer && Platform.isAndroid) {
openVideoUrlInExternalPlayerAndroid(
videoUrl: (item.source as URLSource).url,
playerPackage: config.currentPlayerPackage,
);
return;
}
}
}
}
final meta = (widget.id as Meta).copyWith();
Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) => DocViewer(
source: item.source,
service: widget.service,
meta: meta,
progress: widget.progress,
),
),
);
},
);
},
itemCount: filteredList.length + 1,
);
}
}
String calculateHash(String url) {
return url.hashCode.toString();
}

View file

@ -1,461 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
class StremioCard extends StatefulWidget {
final LibraryItem item;
final String prefix;
final String connectionId;
final BaseConnectionService service;
const StremioCard({
super.key,
required this.item,
required this.prefix,
required this.connectionId,
required this.service,
});
@override
State<StremioCard> createState() => _StremioCardState();
}
class _StremioCardState extends State<StremioCard> {
@override
Widget build(BuildContext context) {
final meta = widget.item as Meta;
return Card(
margin: const EdgeInsets.only(right: 8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
context.push(
"/info/stremio/${widget.connectionId}/${meta.type}/${meta.id}?hero=${widget.prefix}${meta.type}${widget.item.id}",
extra: {
'meta': meta,
'service': widget.service,
},
);
},
child: ((meta.currentVideo == null || meta.progress != null) ||
(meta.forceRegular == true))
? _buildRegular(context, meta)
: _buildWideCard(context, meta),
),
),
);
}
_buildWideCard(BuildContext context, Meta meta) {
return WideCardStremio(meta: meta);
}
String? getBackgroundImage(Meta meta) {
String? backgroundImage;
if (meta.currentVideo != null) {
return meta.currentVideo?.thumbnail ?? meta.poster;
}
if (meta.poster != null) {
backgroundImage = meta.poster;
}
return backgroundImage;
}
_buildRegular(BuildContext context, Meta meta) {
final backgroundImage =
meta.poster ?? meta.logo ?? getBackgroundImage(meta);
return Hero(
tag: "${widget.prefix}${meta.type}${widget.item.id}",
child: (backgroundImage == null)
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: Center(
child: Icon(
Icons.image_not_supported,
size: 26,
),
),
),
Container(
color: Colors.grey,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
meta.name ?? "No title",
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.black54,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
)
: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(backgroundImage)}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.cover,
),
),
child: meta.imdbRating != ""
? Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
meta.imdbRating,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
),
if (meta.progress != null)
const Positioned.fill(
child: IconButton(
onPressed: null,
icon: Icon(
Icons.play_arrow,
size: 24,
),
),
),
if (meta.progress != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: meta.progress! / 100,
minHeight: 5,
),
),
if (meta.currentVideo != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.grey,
Colors.transparent,
],
begin: Alignment.bottomLeft,
end: Alignment.topRight,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
meta.name ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
"S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
),
)
],
),
);
}
}
class WideCardStremio extends StatefulWidget {
final Meta meta;
final Video? video;
const WideCardStremio({
super.key,
required this.meta,
this.video,
});
@override
State<WideCardStremio> createState() => _WideCardStremioState();
}
class _WideCardStremioState extends State<WideCardStremio> {
bool hasErrorWhileLoading = false;
bool get isInFuture {
final video = widget.video ?? widget.meta.currentVideo;
return video != null &&
video.firstAired != null &&
video.firstAired!.isAfter(DateTime.now());
}
@override
Widget build(BuildContext context) {
if (widget.meta.background == null) {
return Container();
}
final video = widget.video ?? widget.meta.currentVideo;
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(
hasErrorWhileLoading
? widget.meta.background!
: (widget.meta.currentVideo?.thumbnail ??
widget.meta.background!),
)}@webp",
errorListener: (error) {
setState(() {
hasErrorWhileLoading = true;
});
},
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.cover,
),
),
child: Stack(
children: [
if (isInFuture)
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Colors.black54,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Colors.transparent,
],
begin: Alignment.bottomLeft,
end: Alignment.center,
),
),
),
),
Positioned(
bottom: 0,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"${widget.meta.name}",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(
height: 4,
),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
"S${video?.season} E${video?.episode}",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.black,
),
),
),
Text(
"${video?.name ?? video?.title}".trim(),
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
),
if (isInFuture)
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
getRelativeDate(video!.firstAired!),
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (isInFuture)
const Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 10,
),
child: Icon(
Icons.calendar_month,
),
),
],
),
),
),
const Positioned(
child: Center(
child: IconButton.filled(
onPressed: null,
icon: Icon(
Icons.play_arrow,
size: 24,
),
),
),
),
widget.meta.imdbRating != "" && widget.video == null
? Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
widget.meta.imdbRating,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
)
: const SizedBox.shrink(),
],
),
);
}
}
String getRelativeDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = DateTime(now.year, now.month, now.day + 1);
final difference = date.difference(today).inDays;
if (date.isAtSameMomentAs(today)) {
return "It's today!";
} else if (date.isAtSameMomentAs(tomorrow)) {
return "Coming up tomorrow!";
} else if (difference > 1 && difference < 7) {
return "Coming up in $difference days";
} else if (difference >= 7 && difference < 14) {
return "Coming up next ${DateFormat('EEEE').format(date)}";
} else {
return "On ${DateFormat('MM/dd/yyyy').format(date)}";
}
}

View file

@ -1,12 +0,0 @@
import 'package:flutter/material.dart';
class StremioCreateConnection extends StatelessWidget {
const StremioCreateConnection({
super.key,
});
@override
Widget build(BuildContext context) {
return Container();
}
}

View file

@ -1,130 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/connection/services/stremio_service.dart';
import '../../service/base_connection_service.dart';
typedef FilterCallback = void Function(List<ConnectionFilterItem> item);
class InlineFilters extends StatefulWidget {
final List<ConnectionFilter<dynamic>> filters;
final FilterCallback filterCallback;
const InlineFilters({
super.key,
required this.filters,
required this.filterCallback,
});
@override
State<InlineFilters> createState() => _InlineFiltersState();
}
class _InlineFiltersState extends State<InlineFilters> {
final Map<String, dynamic> _selectedValues = {};
List<ConnectionFilterItem> generateFilterItem() {
final List<ConnectionFilterItem> items = [];
for (final item in _selectedValues.keys) {
items.add(
ConnectionFilterItem(title: item, value: _selectedValues[item]!),
);
}
return items;
}
onChange() {
widget.filterCallback(generateFilterItem());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: widget.filters
.where((filter) => filter.type == ConnectionFilterType.options)
.map((filter) {
final isSelected = _selectedValues.containsKey(filter.title);
return Center(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: InputChip(
label: Text(
(isSelected ? _selectedValues[filter.title] : filter.title)
.toString()
.capitalize(),
style: TextStyle(
fontSize: 14,
color: theme.textTheme.bodyMedium?.color,
),
),
selected: isSelected,
onPressed: () {
if (isSelected) {
setState(() {
_selectedValues.remove(filter.title);
});
onChange();
} else {
_showOptionsDialog(filter);
}
},
deleteIcon: isSelected
? const Icon(
Icons.close,
)
: null,
onDeleted: isSelected
? () {
setState(() {
_selectedValues.remove(filter.title);
onChange();
});
}
: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
);
}).toList(),
),
);
}
void _showOptionsDialog(ConnectionFilter<dynamic> filter) async {
final selectedValue = await showDialog<dynamic>(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
title: Text(filter.title),
children: (filter.values ?? []).map((value) {
return SimpleDialogOption(
onPressed: () {
Navigator.pop(context, value);
},
child: Text(value.toString()),
);
}).toList(),
);
},
);
if (selectedValue != null) {
setState(() {
_selectedValues[filter.title] = selectedValue;
});
onChange();
}
}
}

View file

@ -1,520 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
import 'package:madari_client/features/connections/widget/stremio/stremio_season_selector.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../types/stremio/stremio_base.types.dart';
class StremioItemViewer extends StatefulWidget {
final Meta? meta;
final Meta? original;
final String? hero;
final BaseConnectionService? service;
final num? progress;
const StremioItemViewer({
super.key,
this.meta,
this.original,
this.hero,
this.service,
this.progress,
});
@override
State<StremioItemViewer> createState() => _StremioItemViewerState();
}
class _StremioItemViewerState extends State<StremioItemViewer> {
String? _errorMessage;
@override
void initState() {
super.initState();
}
bool get _isLoading {
return widget.original == null;
}
Meta? _item;
Meta? get item {
return _item ?? widget.meta;
}
void _onPlayPressed(BuildContext context) {
if (item == null) {
return;
}
showModalBottomSheet(
context: context,
builder: (context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
),
title: const Text("Streams"),
),
body: widget.service == null
? const Center(
child: CircularProgressIndicator(),
)
: RenderStreamList(
service: widget.service!,
id: widget.meta as LibraryItem,
shouldPop: false,
),
);
},
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isWideScreen = screenWidth > 900;
final contentWidth = isWideScreen ? 900.0 : screenWidth;
if (_errorMessage != null) {
return Text("Failed $_errorMessage");
}
if (item == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: isWideScreen ? 600 : 500,
pinned: true,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(40),
child: Container(
width: double.infinity,
color: Colors.black,
padding: EdgeInsets.symmetric(
horizontal:
isWideScreen ? (screenWidth - contentWidth) / 2 : 16,
vertical: 16,
),
child: Row(
children: [
Expanded(
child: Text(
(item!.name ?? "No name"),
style: Theme.of(context).textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
icon: _isLoading
? Container(
margin: const EdgeInsets.only(right: 6),
child: const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
),
)
: const Icon(
Icons.play_arrow_rounded,
size: 24,
color: Colors.black87,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
),
onPressed: () {
if (item!.type == "series" && _isLoading) {
return;
}
_onPlayPressed(context);
},
label: Text(
widget.progress != null && widget.progress != 0
? "Resume"
: "Play",
style: Theme.of(context)
.primaryTextTheme
.bodyMedium
?.copyWith(
color: Colors.black87,
),
),
),
],
),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (item!.background != null)
Image.network(
item!.background!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
if (item!.poster == null) {
return Container();
}
return Image.network(item!.poster!,
fit: BoxFit.cover);
},
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.8),
],
),
),
),
Positioned(
bottom: 86,
left: 16,
right: 16,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: isWideScreen
? (screenWidth - contentWidth) / 2
: 16,
vertical: 16,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Hero(
tag: "${widget.hero}",
child: Container(
width: 150,
height: 225,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
image: item!.poster == null
? null
: DecorationImage(
image: NetworkImage(item!.poster!),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
spreadRadius: 2,
blurRadius: 8,
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item!.year != null)
Chip(
label: Text("${item!.year ?? ""}"),
backgroundColor: Colors.white24,
labelStyle: const TextStyle(
color: Colors.white),
),
const SizedBox(width: 8),
if (item!.imdbRating != null)
Row(
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 20,
),
const SizedBox(width: 4),
Text(
item!.imdbRating!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Colors.white),
),
],
),
],
),
],
),
),
],
),
),
),
],
),
),
),
if (widget.original != null &&
widget.original?.type == "series" &&
widget.original?.videos?.isNotEmpty == true)
StremioItemSeasonSelector(
meta: (item as Meta).copyWith(
selectedVideoIndex: widget.meta?.selectedVideoIndex,
),
service: widget.service,
),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal:
isWideScreen ? (screenWidth - contentWidth) / 2 : 16,
vertical: 16,
),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (widget.original != null)
const SizedBox(
height: 12,
),
Text(
'Description',
style: Theme.of(context).textTheme.titleLarge,
),
if (item!.description != null) const SizedBox(height: 8),
if (item!.description != null)
Text(
item!.description!,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
// Additional Details
_buildDetailSection(context, 'Additional Information', [
if (item!.genre != null)
_buildDetailRow('Genres', item!.genre!.join(', ')),
if (item!.country != null)
_buildDetailRow('Country', item!.country!),
if (item!.runtime != null)
_buildDetailRow('Runtime', item!.runtime!),
if (item!.language != null)
_buildDetailRow('Language', item!.language!),
]),
// Cast
if (item!.creditsCast != null &&
item!.creditsCast!.isNotEmpty)
_buildCastSection(context, item!.creditsCast!),
// Cast
if (item!.creditsCrew != null &&
item!.creditsCrew!.isNotEmpty)
_buildCastSection(
context,
title: "Crew",
item!.creditsCrew!.map((item) {
return CreditsCast(
character: item.department,
name: item.name,
profilePath: item.profilePath,
id: item.id,
);
}).toList(),
),
// Trailers
if (item!.trailerStreams != null &&
item!.trailerStreams!.isNotEmpty)
_buildTrailersSection(context, item!.trailerStreams!),
]),
),
),
],
),
),
);
}
Widget _buildDetailSection(
BuildContext context, String title, List<Widget> details) {
if (details.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
...details,
const SizedBox(height: 16),
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(value)),
],
),
);
}
Widget _buildCastSection(
BuildContext context,
List<CreditsCast> cast, {
String title = "Cast",
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: cast.length,
itemBuilder: (context, index) {
final actor = cast[index];
return Padding(
padding: const EdgeInsets.only(right: 16),
child: Column(
children: [
CircleAvatar(
radius: 50,
backgroundImage: actor.profilePath != null
? CachedNetworkImageProvider(
actor.profilePath!.startsWith("/")
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://image.tmdb.org/t/p/original/${actor.profilePath}")}@webp"
: actor.profilePath!,
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
)
: null,
child: actor.profilePath == null
? Icon(
Icons.person,
size: 50,
color: Colors.grey[300],
)
: null,
),
const SizedBox(height: 8),
Text(
actor.name,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
actor.character,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
},
),
),
const SizedBox(height: 16),
],
);
}
Widget _buildTrailersSection(
BuildContext context, List<TrailerStream> trailers) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trailers',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: trailers.length,
itemBuilder: (context, index) {
final trailer = trailers[index];
return GestureDetector(
onTap: () async {
final url = Uri.parse(
"https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1",
);
launchUrl(
url,
);
},
child: Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.black26,
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.contain,
),
),
child: Center(
child: Text(
trailer.title,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
),
),
),
),
);
},
),
),
const SizedBox(
height: 12,
),
],
);
}
}

View file

@ -1,476 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../types/stremio/stremio_base.types.dart';
class StremioItemViewerTV extends StatefulWidget {
final Meta? meta;
final Meta? original;
final String? hero;
final BaseConnectionService? service;
final String library;
const StremioItemViewerTV({
super.key,
this.meta,
this.original,
this.hero,
this.service,
required this.library,
});
@override
State<StremioItemViewerTV> createState() => _StremioItemViewerTVState();
}
class _StremioItemViewerTVState extends State<StremioItemViewerTV> {
String? _errorMessage;
final FocusNode _playButtonFocusNode = FocusNode();
final FocusNode _trailersFocusNode = FocusNode();
bool _showTrailers = false;
@override
void initState() {
super.initState();
// Set initial focus to the Play button
_playButtonFocusNode.requestFocus();
}
@override
void dispose() {
_playButtonFocusNode.dispose();
_trailersFocusNode.dispose();
super.dispose();
}
bool get _isLoading {
return widget.original == null;
}
Meta? _item;
Meta? get item {
return _item ?? widget.meta;
}
void _onPlayPressed(BuildContext context) {
if (item == null) {
return;
}
showModalBottomSheet(
context: context,
builder: (context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
),
title: const Text("Streams"),
),
body: widget.service == null
? const Center(
child: CircularProgressIndicator(),
)
: RenderStreamList(
service: widget.service!,
id: widget.meta as LibraryItem,
shouldPop: false,
),
);
},
);
}
@override
Widget build(BuildContext context) {
if (_errorMessage != null) {
return Center(
child: Text("Failed $_errorMessage"),
);
}
if (item == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Scaffold(
body: Stack(
children: [
// Static Background
if (item!.background != null)
Positioned.fill(
child: Image.network(
item!.background!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
if (item!.poster == null) {
return Container();
}
return Image.network(item!.poster!, fit: BoxFit.cover);
},
),
),
// Gradient Overlay
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.8),
],
),
),
),
),
// Content
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
item!.name ?? "No Title",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
// Poster and Details Section
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 900,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
Hero(
tag: "${widget.hero}",
child: Container(
width: 150,
height: 225,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
image: item!.poster == null
? null
: DecorationImage(
image:
NetworkImage(item!.poster!),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color:
Colors.black.withOpacity(0.3),
spreadRadius: 2,
blurRadius: 8,
),
],
),
),
),
const SizedBox(width: 16),
// Details
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Year and Rating
Row(
children: [
if (item!.year != null)
Chip(
label:
Text("${item!.year ?? ""}"),
backgroundColor: Colors.white24,
labelStyle: const TextStyle(
color: Colors.white),
),
const SizedBox(width: 8),
if (item!.imdbRating != "")
Row(
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 20,
),
const SizedBox(width: 4),
Text(
item!.imdbRating,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color:
Colors.white),
),
],
),
],
),
const SizedBox(height: 16),
// Description
Text(
'Description',
style: Theme.of(context)
.textTheme
.titleLarge,
),
if (item!.description != null)
const SizedBox(height: 8),
if (item!.description != null)
Text(
item!.description!,
style: Theme.of(context)
.textTheme
.bodyMedium,
),
const SizedBox(height: 16),
// Additional Details
_buildDetailSection(
context, 'Additional Information', [
if (item!.genre != null)
_buildDetailRow('Genres',
item!.genre!.join(', ')),
if (item!.country != null)
_buildDetailRow(
'Country', item!.country!),
if (item!.runtime != null)
_buildDetailRow(
'Runtime', item!.runtime!),
if (item!.language != null)
_buildDetailRow(
'Language', item!.language!),
]),
],
),
),
],
),
const SizedBox(height: 16),
// Play Button
Focus(
focusNode: _playButtonFocusNode,
onKey: (node, event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey ==
LogicalKeyboardKey.arrowDown) {
// Show Trailers
setState(() {
_showTrailers = true;
});
FocusScope.of(context)
.requestFocus(_trailersFocusNode);
return KeyEventResult.handled;
} else if (event.logicalKey ==
LogicalKeyboardKey.enter) {
// Play the item
_onPlayPressed(context);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: ElevatedButton.icon(
icon: _isLoading
? Container(
margin:
const EdgeInsets.only(right: 6),
child: const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
),
)
: const Icon(
Icons.play_arrow_rounded,
size: 24,
color: Colors.black87,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
),
onPressed: () {
if (item!.type == "series" && _isLoading) {
return;
}
_onPlayPressed(context);
},
label: Text(
"Play",
style: Theme.of(context)
.primaryTextTheme
.bodyMedium
?.copyWith(
color: Colors.black87,
),
),
),
),
],
),
),
),
),
),
),
if (_showTrailers &&
item!.trailerStreams != null &&
item!.trailerStreams!.isNotEmpty)
Focus(
focusNode: _trailersFocusNode,
onKey: (node, event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
// Hide Trailers and move focus back to Play Button
setState(() {
_showTrailers = false;
});
FocusScope.of(context)
.requestFocus(_playButtonFocusNode);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child:
_buildTrailersSection(context, item!.trailerStreams!),
),
],
),
),
],
),
);
}
Widget _buildDetailSection(
BuildContext context, String title, List<Widget> details) {
if (details.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
...details,
const SizedBox(height: 16),
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(value)),
],
),
);
}
Widget _buildTrailersSection(
BuildContext context, List<TrailerStream> trailers) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trailers',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: trailers.length,
itemBuilder: (context, index) {
final trailer = trailers[index];
return GestureDetector(
onTap: () async {
final url = Uri.parse(
"https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1",
);
launchUrl(
url,
);
},
child: Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.black26,
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.contain,
),
),
child: Center(
child: Text(
trailer.title,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
),
),
),
),
);
},
),
),
],
),
);
}
}

View file

@ -1,17 +0,0 @@
import 'package:flutter/material.dart';
import '../../service/base_connection_service.dart';
class StremioListItem extends StatelessWidget {
final LibraryItem item;
const StremioListItem({
super.key,
required this.item,
});
@override
Widget build(BuildContext context) {
return const ListTile();
}
}

View file

@ -1,473 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
import 'package:madari_client/features/trakt/service/trakt.service.dart';
import 'package:madari_client/utils/common.dart';
import '../../../doc_viewer/types/doc_source.dart';
import '../../../watch_history/service/base_watch_history.dart';
import '../../../watch_history/service/zeee_watch_history.dart';
class StremioItemSeasonSelector extends StatefulWidget {
final Meta meta;
final int? season;
final BaseConnectionService? service;
final bool shouldPop;
const StremioItemSeasonSelector({
super.key,
required this.meta,
this.season,
required this.service,
this.shouldPop = false,
});
@override
State<StremioItemSeasonSelector> createState() =>
_StremioItemSeasonSelectorState();
}
class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
with SingleTickerProviderStateMixin {
int? selectedSeason;
late TabController? _tabController;
late final Map<int, List<Video>> seasonMap;
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
late Meta meta = widget.meta;
final Map<String, double> _progress = {};
@override
void initState() {
super.initState();
seasonMap = _organizeEpisodes();
if (seasonMap.keys.isEmpty) {
return;
}
final index = getSelectedSeason();
_tabController = TabController(
length: seasonMap.keys.length,
vsync: this,
initialIndex: index.clamp(
0,
seasonMap.keys.isNotEmpty ? seasonMap.keys.length - 1 : 0,
),
);
// This is for rendering the component again for the selection of another tab
_tabController!.addListener(() {
setState(() {});
});
getWatchHistory();
}
int getSelectedSeason() {
return widget.meta.currentVideo?.season ??
widget.meta.videos?.lastWhereOrNull((item) {
return item.progress != null;
})?.season ??
widget.season ??
0;
}
getWatchHistory() async {
final traktService = TraktService.instance;
try {
if (TraktService.isEnabled()) {
final result = await traktService!.getProgress(
widget.meta,
bypassCache: false,
);
setState(() {
meta = result;
});
final index = getSelectedSeason();
_tabController?.animateTo(index);
return;
}
} catch (e, stack) {
print(e);
print(stack);
print("Unable to get trakt progress");
}
final docs = await zeeeWatchHistory!.getItemWatchHistory(
ids: widget.meta.videos!.map((item) {
return WatchHistoryGetRequest(
id: item.id,
episode: item.episode.toString(),
season: item.season.toString(),
);
}).toList(),
);
for (var item in docs) {
_progress[item.id] = item.progress.toDouble();
}
final index = getSelectedSeason();
_tabController?.animateTo(index);
}
@override
void dispose() {
_tabController?.dispose();
super.dispose();
}
Map<int, List<Video>> _organizeEpisodes() {
final episodes = meta.videos ?? [];
return groupBy(episodes, (Video video) => video.season);
}
void openEpisode({
required int index,
}) async {
if (widget.service == null) {
return;
}
final onClose = showModalBottomSheet(
context: context,
builder: (context) {
final meta = this.meta.copyWith(
selectedVideoIndex: index,
);
return Scaffold(
appBar: AppBar(
title: Text(
"Streams for S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
),
),
body: RenderStreamList(
service: widget.service!,
id: meta,
shouldPop: widget.shouldPop,
),
);
},
);
if (widget.shouldPop) {
final val = await onClose;
if (val is MediaURLSource && context.mounted && mounted) {
Navigator.pop(
context,
val,
);
}
return;
}
onClose.then((data) {
getWatchHistory();
});
}
@override
Widget build(BuildContext context) {
final seasons = seasonMap.keys.toList()..sort();
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final isWideScreen = screenWidth > 900;
final contentWidth = isWideScreen ? 900.0 : screenWidth;
if (_tabController == null) {
return const SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
SizedBox(
height: 0,
)
],
),
),
],
);
}
return SliverMainAxisGroup(
slivers: [
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8,
),
sliver: SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 12,
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
child: ElevatedButton.icon(
icon: const Icon(Icons.shuffle),
label: const Text("Random Episode"),
onPressed: () {
Random random = Random();
int randomIndex = random.nextInt(
widget.meta.videos!.length,
);
openEpisode(index: randomIndex);
},
),
),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: TabBar(
tabAlignment: TabAlignment.start,
dividerColor: Colors.transparent,
controller: _tabController,
isScrollable: true,
splashBorderRadius: BorderRadius.circular(8),
padding: const EdgeInsets.all(4),
tabs: seasons.map((season) {
return Tab(
text: season == 0 ? "Specials" : 'Season $season',
height: 40,
);
}).toList(),
),
),
const SizedBox(height: 16),
],
),
),
),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final currentSeason = seasons[_tabController!.index];
final episodes = seasonMap[currentSeason]!;
final episode = episodes[index];
final videoIndex = meta.videos?.indexOf(episode);
final progress = ((!TraktService.isEnabled()
? (_progress[episode.id] ?? 0) / 100
: videoIndex != -1
? (meta.videos![videoIndex!].progress)
: 0.toDouble()) ??
0) /
100;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
if (videoIndex != null) {
openEpisode(
index: videoIndex,
);
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: 8.0,
top: 8.0,
bottom: 8.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Container(
child: episode.thumbnail != null &&
episode.thumbnail!.isNotEmpty
? Image.network(
episode.thumbnail!,
width: 140,
height: 90,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) {
return Container(
width: 140,
height: 90,
color: colorScheme
.surfaceContainerHighest,
child: Icon(
Icons.movie,
color:
colorScheme.onSurfaceVariant,
),
);
},
)
: Container(
width: 140,
height: 90,
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.movie,
color: colorScheme.onSurfaceVariant,
),
),
),
Positioned(
top: 0,
bottom: 0,
right: 0,
left: 0,
child: Stack(
children: [
const Center(
child: Icon(
Icons.play_arrow,
),
),
Center(
child: CircularProgressIndicator(
value: progress,
),
)
],
),
),
if (progress > .9)
Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.teal,
),
child: Padding(
padding: const EdgeInsets.only(
right: 4.0,
bottom: 2.0,
left: 4.0,
top: 2.0,
),
child: Center(
child: Text(
"Watched",
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Colors.black,
),
),
),
),
),
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 8.0,
),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${index + 1}. ${episode.name ?? 'Episode ${episode.episode}'}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (episode.released != null) ...[
const SizedBox(height: 4),
Text(
intl.DateFormat('MMMM dd yyyy')
.format(episode.released!),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface
.withOpacity(0.7),
),
),
],
if (episode.overview != null) ...[
Text(
episode.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface
.withOpacity(0.9),
),
),
],
],
),
),
),
),
],
),
);
},
childCount:
seasonMap[seasons[_tabController!.index]]?.length ?? 0,
),
),
),
],
);
}
}
Map<T, List<E>> groupBy<E, T>(Iterable<E> items, T Function(E) key) {
final map = <T, List<E>>{};
for (final item in items) {
final keyValue = key(item);
if (!map.containsKey(keyValue)) {
map[keyValue] = [];
}
map[keyValue]!.add(item);
}
return map;
}

View file

@ -1,128 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/pdf_viewer.dart';
import 'package:madari_client/features/doc_viewer/container/photo_viewer.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'iframe.dart';
class DocViewer extends StatefulWidget {
final DocSource source;
final String? library;
final LibraryItem? meta;
final String? season;
final BaseConnectionService? service;
final double? progress;
const DocViewer({
super.key,
required this.source,
this.service,
this.library,
this.meta,
this.season,
this.progress,
});
@override
State<DocViewer> createState() => _DocViewerState();
}
class _DocViewerState extends State<DocViewer> {
bool isReady = false;
String? _errorMessage;
@override
void dispose() {
super.dispose();
widget.source.dispose();
}
@override
void initState() {
super.initState();
widget.source.init().then((_) {
setState(() {
isReady = true;
});
}).catchError((err) {
setState(() {
if (mounted) {
_errorMessage = err.toString();
}
});
widget.source.dispose();
});
}
@override
Widget build(BuildContext context) {
if (!isReady) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text(widget.source.title),
),
body: const Center(
child: CircularProgressIndicator(),
),
);
}
if (_errorMessage != null) {
return Text("Error $_errorMessage");
}
if (widget.source is IframeSource) {
return IframeViewer(
source: widget.source as IframeSource,
);
}
switch (widget.source.getType()) {
case DocType.pdf:
return PDFViewerContainer(source: widget.source);
case DocType.photo:
return PhotoViewer(source: widget.source);
case DocType.video:
return VideoViewer(
source: widget.source,
meta: widget.meta,
service: widget.service,
currentSeason: widget.season,
library: widget.library,
);
default:
return Scaffold(
extendBody: true,
appBar: AppBar(
title: Text(widget.source.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.broken_image,
size: 42,
),
const SizedBox(
height: 12,
),
Text(
"Unsupported file ${widget.source.title}",
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
);
}
}
}

View file

@ -1,130 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
class AdblockList {
static String str = "";
}
class IframeViewer extends StatefulWidget {
final IframeSource source;
const IframeViewer({
super.key,
required this.source,
});
@override
State<IframeViewer> createState() => _IframeViewerState();
}
class _IframeViewerState extends State<IframeViewer> {
final List<ContentBlocker> contentBlockers = [];
bool _isFullScreen = false;
@override
void initState() {
super.initState();
final url = AdblockList.str
.split("\n")
.where((item) => item.trim() != "")
.map((item) {
return ".*.$item/.*";
}).toList();
for (final adUrlFilter in url) {
contentBlockers.add(
ContentBlocker(
trigger: ContentBlockerTrigger(
urlFilter: adUrlFilter,
),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
);
}
contentBlockers.add(
ContentBlocker(
trigger: ContentBlockerTrigger(
urlFilter: ".*",
),
action: ContentBlockerAction(
type: ContentBlockerActionType.CSS_DISPLAY_NONE,
selector: """
.banner, .banners, .ads, .ad, .advert, .advertisement,
[class*="ad-"], [class*="Ad"], [class*="advertisement"],
[id*="google_ads"], [id*="ad-"],
iframe[src*="ads"], iframe[src*="doubleclick"],
div[aria-label*="advertisement"],
.social-share, .newsletter-signup,
.popup, .modal-overlay, .cookie-notice,
[class*="cookie-banner"], [id*="cookie-consent"]
""",
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IconButton(
icon: Icon(
_isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.black,
),
onPressed: () {
setState(() {
_isFullScreen = !_isFullScreen;
});
if (_isFullScreen) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
},
),
],
),
body: InAppWebView(
initialSettings: InAppWebViewSettings(
contentBlockers: contentBlockers,
useShouldOverrideUrlLoading: true,
iframeAllow:
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
iframeCsp: "",
iframeReferrerPolicy: ReferrerPolicy.ORIGIN,
iframeAllowFullscreen: true,
),
initialUrlRequest: URLRequest(
url: WebUri(
widget.source.url,
),
),
),
);
}
@override
void dispose() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
super.dispose();
}
}

View file

@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../../engine/engine.dart';
import 'magic_page_selector_bottom_sheet.dart';
class MagicBottomSheet extends StatefulWidget {
final PdfViewerController controller;
const MagicBottomSheet({
super.key,
required this.controller,
});
@override
State<MagicBottomSheet> createState() => _MagicBottomSheetState();
}
class _MagicBottomSheetState extends State<MagicBottomSheet> {
final pb = AppEngine.engine.pb;
late Future<ResultList<RecordModel>> item;
@override
void initState() {
super.initState();
item = pb.collection("ai_action").getList(perPage: 100);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: item,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(
body: Text("Error: ${snapshot.error}"),
);
}
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {},
),
title: const Text("AI Actions"),
),
body: ListView.builder(
itemCount: snapshot.data!.items.length,
itemBuilder: (ctx, index) {
final item = snapshot.data!.items[index];
final description = item.getStringValue("description");
return ListTile(
onTap: () async {
final result = await showModalBottomSheet(
context: context,
builder: (ctx) {
return MagicPageSelectorBottomSheet(
item: item,
controller: widget.controller,
);
},
);
if (context.mounted && mounted) {
Navigator.pop(context, [item, result]);
}
},
leading: const Icon(Icons.question_answer_outlined),
title: Text(
snapshot.data!.items[index].getStringValue("title"),
),
subtitle: description != ""
? Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
: null,
);
},
),
);
},
);
}
}

View file

@ -1,134 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pocketbase/pocketbase.dart';
class MagicPageSelectorBottomSheet extends StatelessWidget {
final RecordModel item;
final PdfViewerController controller;
const MagicPageSelectorBottomSheet({
super.key,
required this.item,
required this.controller,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Select 📃"),
),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.file_present),
title: const Text('Current Page'),
subtitle: Text('Page ${controller.pageNumber}'),
onTap: () {
Navigator.pop(context, [controller.pageNumber!]);
},
),
ListTile(
leading: const Icon(Icons.filter_frames),
title: const Text('Page Range'),
subtitle: const Text('Select a range of pages'),
onTap: () async {
final RangeValues? result = await showDialog<RangeValues>(
context: context,
builder: (BuildContext context) {
return PageRangeDialog(
maxPages: controller.pageCount,
);
},
);
if (result != null) {
final List<int> pages = List.generate(
(result.end - result.start + 1).toInt(),
(index) => index + result.start.toInt(),
);
if (context.mounted) Navigator.pop(context, pages);
}
},
),
ListTile(
leading: const Icon(Icons.all_inclusive),
title: const Text('All Pages'),
subtitle: Text('Total ${controller.pageCount} pages'),
onTap: () {
final List<int> allPages = List.generate(
controller.pageCount,
(index) => index + 1,
);
Navigator.pop(context, allPages);
},
),
],
),
);
}
}
// Additional dialog for page range selection
class PageRangeDialog extends StatefulWidget {
final int maxPages;
const PageRangeDialog({
super.key,
required this.maxPages,
});
@override
State<PageRangeDialog> createState() => _PageRangeDialogState();
}
class _PageRangeDialogState extends State<PageRangeDialog> {
late RangeValues _currentRangeValues;
@override
void initState() {
super.initState();
_currentRangeValues = RangeValues(1, widget.maxPages.toDouble());
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Select Page Range'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RangeSlider(
values: _currentRangeValues,
min: 1,
max: widget.maxPages.toDouble(),
divisions: widget.maxPages - 1,
labels: RangeLabels(
_currentRangeValues.start.round().toString(),
_currentRangeValues.end.round().toString(),
),
onChanged: (RangeValues values) {
setState(() {
_currentRangeValues = values;
});
},
),
Text(
'Pages ${_currentRangeValues.start.round()} to ${_currentRangeValues.end.round()}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _currentRangeValues),
child: const Text('OK'),
),
],
);
}
}

View file

@ -1,327 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:url_launcher/url_launcher.dart';
class MagicShowMarkdown extends StatefulWidget {
final RecordModel record;
final List<int> pages;
final PdfViewerController controller;
final String fileName;
const MagicShowMarkdown({
super.key,
required this.record,
required this.pages,
required this.controller,
required this.fileName,
});
@override
State<MagicShowMarkdown> createState() => _MagicShowMarkdownState();
}
class _MagicShowMarkdownState extends State<MagicShowMarkdown> {
final List<String> markdownChunks = [];
bool isLoading = true;
String? error;
bool isStreaming = false;
final Set<int> selectedChunks = {};
bool isSelectionMode = false;
final RegExp mermaidRegex = RegExp(r'```mermaid\n([\s\S]*?)```');
@override
void dispose() {
super.dispose();
}
@override
void initState() {
super.initState();
_extractAndStream();
}
Future<String> _extractPdfText() async {
return "";
}
void _extractAndStream() async {}
void _retryStreaming() {
setState(() {
error = null;
isLoading = true;
markdownChunks.clear();
});
_extractAndStream();
}
void _handleMarkdownTap(String text, String? href) {
if (href != null) {
launchUrl(Uri.parse(href));
}
}
void _copyText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard')),
);
}
void _deleteChunk(int index) {
setState(() {
markdownChunks.removeAt(index);
selectedChunks.remove(index);
});
}
void _saveSelectedChunks() async {
try {
final selectedTexts = selectedChunks.toList()..sort();
final textItem =
selectedTexts.map((index) => markdownChunks[index]).toList();
await AppEngine.engine.pb.collection('saved_responses').create(
body: {
'content': textItem.join("\n\n"),
'file_name': widget.fileName,
'user': AppEngine.engine.pb.authStore.record!.id,
},
);
if (context.mounted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saved successfully')),
);
}
setState(() {
isSelectionMode = false;
selectedChunks.clear();
});
} catch (e) {
if (context.mounted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Error saving: ${(e as ClientException).response["message"]}',
),
),
);
}
}
}
Widget _buildMarkdownContent(String chunk) {
final mermaidMatches = mermaidRegex.allMatches(chunk);
if (mermaidMatches.isNotEmpty) {
List<Widget> contentWidgets = [];
int lastEnd = 0;
// Process each match and the text between matches
for (var match in mermaidMatches) {
// Add markdown content before the diagram if exists
if (match.start > lastEnd) {
final beforeText = chunk.substring(lastEnd, match.start);
if (beforeText.trim().isNotEmpty) {
contentWidgets.add(
MarkdownBody(
data: beforeText,
selectable: true,
onTapLink: (text, href, title) =>
_handleMarkdownTap(text, href),
),
);
}
}
lastEnd = match.end;
}
// Add any remaining markdown content after the last diagram
if (lastEnd < chunk.length) {
final afterText = chunk.substring(lastEnd);
if (afterText.trim().isNotEmpty) {
contentWidgets.add(
MarkdownBody(
data: afterText,
selectable: true,
onTapLink: (text, href, title) => _handleMarkdownTap(text, href),
),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: contentWidgets,
);
}
return MarkdownBody(
data: chunk,
selectable: true,
onTapLink: (text, href, title) => _handleMarkdownTap(text, href),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Output"),
actions: [
if (isSelectionMode) ...[
TextButton.icon(
onPressed: selectedChunks.isEmpty ? null : _saveSelectedChunks,
icon: const Icon(Icons.save),
label: Text('Save ${selectedChunks.length}'),
),
IconButton(
onPressed: () => setState(() {
isSelectionMode = false;
selectedChunks.clear();
}),
icon: const Icon(Icons.close),
),
] else
IconButton(
onPressed: () => setState(() => isSelectionMode = true),
icon: const Icon(Icons.checklist),
),
],
),
body: Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 800,
),
child: ListView.builder(
itemCount:
markdownChunks.length + (isStreaming || error != null ? 1 : 0),
itemBuilder: (context, index) {
if (index == markdownChunks.length) {
if (error != null) {
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading content',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
error!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _retryStreaming,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
return _buildStreamingIndicator();
}
final chunk =
markdownChunks[index].replaceAll("---", "\n").trim();
final isSelected = selectedChunks.contains(index);
return Card(
margin: const EdgeInsets.all(8),
child: InkWell(
onTap: isSelectionMode
? () => setState(() {
if (isSelected) {
selectedChunks.remove(index);
} else {
selectedChunks.add(index);
}
})
: null,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (isSelectionMode)
Checkbox(
value: isSelected,
onChanged: (value) => setState(() {
if (value ?? false) {
selectedChunks.add(index);
} else {
selectedChunks.remove(index);
}
}),
),
Expanded(
child: _buildMarkdownContent(chunk),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: () => _copyText(chunk),
icon: const Icon(Icons.copy),
tooltip: 'Copy',
),
IconButton(
onPressed: () => _deleteChunk(index),
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete',
),
],
),
],
),
),
),
);
},
),
),
),
);
}
Widget _buildStreamingIndicator() {
return Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Loading content...',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}

View file

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
class Marker {
final Color color;
final PdfTextRanges ranges;
Marker(this.color, this.ranges);
}
class MarkersView extends StatefulWidget {
const MarkersView({
super.key,
required this.markers,
this.onTap,
this.onDeleteTap,
});
final List<Marker> markers;
final void Function(Marker ranges)? onTap;
final void Function(Marker ranges)? onDeleteTap;
@override
State<MarkersView> createState() => _MarkersViewState();
}
class _MarkersViewState extends State<MarkersView> {
@override
Widget build(BuildContext context) {
return ListView.builder(
itemBuilder: (context, index) {
final marker = widget.markers[index];
return Padding(
padding: const EdgeInsets.only(bottom: 1),
child: Stack(
children: [
Material(
color: marker.color.withAlpha(100),
child: InkWell(
onTap: () => widget.onTap?.call(marker),
child: SizedBox(
width: double.infinity,
height: 40,
child: Text(
'Page #${marker.ranges.pageNumber} - ${marker.ranges.text}'),
),
),
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => widget.onDeleteTap?.call(marker),
),
),
],
),
);
},
itemCount: widget.markers.length,
);
}
}

View file

@ -1,54 +0,0 @@
//
// Just a rough implementation of the document index
//
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
class OutlineView extends StatelessWidget {
const OutlineView({
super.key,
required this.outline,
required this.controller,
});
final List<PdfOutlineNode>? outline;
final PdfViewerController controller;
@override
Widget build(BuildContext context) {
final list = _getOutlineList(outline, 0).toList();
return SizedBox(
width: list.isEmpty ? 0 : 200,
child: ListView.builder(
itemCount: list.length,
itemBuilder: (context, index) {
final item = list[index];
return InkWell(
onTap: () => controller.goToDest(item.node.dest),
child: Container(
margin: EdgeInsets.only(
left: item.level * 16.0 + 8,
top: 8,
bottom: 8,
),
child: Text(
item.node.title,
softWrap: false,
),
),
);
},
),
);
}
/// Recursively create outline indent structure
Iterable<({PdfOutlineNode node, int level})> _getOutlineList(
List<PdfOutlineNode>? outline, int level) sync* {
if (outline == null) return;
for (var node in outline) {
yield (node: node, level: level);
yield* _getOutlineList(node.children, level + 1);
}
}
}

View file

@ -1,34 +0,0 @@
//
// Simple password dialog
//
import 'package:flutter/material.dart';
Future<String?> passwordDialog(BuildContext context) async {
final textController = TextEditingController();
return await showDialog<String?>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Enter password'),
content: TextField(
controller: textController,
autofocus: true,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
onSubmitted: (value) => Navigator.of(context).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(textController.text),
child: const Text('OK'),
),
],
);
},
);
}

View file

@ -1,376 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:synchronized/extension.dart';
class TextSearchView extends StatefulWidget {
const TextSearchView({
super.key,
required this.textSearcher,
});
final PdfTextSearcher textSearcher;
@override
State<TextSearchView> createState() => _TextSearchViewState();
}
class _TextSearchViewState extends State<TextSearchView> {
final focusNode = FocusNode();
final searchTextController = TextEditingController();
late final pageTextStore =
PdfPageTextCache(textSearcher: widget.textSearcher);
final scrollController = ScrollController();
@override
void initState() {
widget.textSearcher.addListener(_searchResultUpdated);
searchTextController.addListener(_searchTextUpdated);
super.initState();
}
@override
void dispose() {
scrollController.dispose();
widget.textSearcher.removeListener(_searchResultUpdated);
searchTextController.removeListener(_searchTextUpdated);
searchTextController.dispose();
focusNode.dispose();
super.dispose();
}
void _searchTextUpdated() {
widget.textSearcher.startTextSearch(searchTextController.text);
}
int? _currentSearchSession;
final _matchIndexToListIndex = <int>[];
final _listIndexToMatchIndex = <int>[];
void _searchResultUpdated() {
if (_currentSearchSession != widget.textSearcher.searchSession) {
_currentSearchSession = widget.textSearcher.searchSession;
_matchIndexToListIndex.clear();
_listIndexToMatchIndex.clear();
}
for (int i = _matchIndexToListIndex.length;
i < widget.textSearcher.matches.length;
i++) {
if (i == 0 ||
widget.textSearcher.matches[i - 1].pageNumber !=
widget.textSearcher.matches[i].pageNumber) {
_listIndexToMatchIndex.add(-widget.textSearcher.matches[i]
.pageNumber); // negative index to indicate page header
}
_matchIndexToListIndex.add(_listIndexToMatchIndex.length);
_listIndexToMatchIndex.add(i);
}
if (mounted) setState(() {});
}
static const double itemHeight = 50;
@override
Widget build(BuildContext context) {
return Column(
children: [
widget.textSearcher.isSearching
? LinearProgressIndicator(
value: widget.textSearcher.searchProgress,
minHeight: 4,
)
: const SizedBox(height: 4),
Row(
children: [
const SizedBox(width: 8),
Expanded(
child: Stack(
alignment: Alignment.centerLeft,
children: [
TextField(
autofocus: true,
focusNode: focusNode,
controller: searchTextController,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(right: 50),
),
textInputAction: TextInputAction.search,
onSubmitted: (value) {
focusNode.requestFocus();
},
),
if (widget.textSearcher.hasMatches)
Align(
alignment: Alignment.centerRight,
child: Text(
'${widget.textSearcher.currentIndex! + 1} / ${widget.textSearcher.matches.length}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
),
],
),
),
const SizedBox(width: 4),
IconButton(
onPressed: (widget.textSearcher.currentIndex ?? 0) <
widget.textSearcher.matches.length
? () async {
await widget.textSearcher.goToNextMatch();
_conditionScrollPosition();
}
: null,
icon: const Icon(Icons.arrow_downward),
iconSize: 20,
),
IconButton(
onPressed: (widget.textSearcher.currentIndex ?? 0) > 0
? () async {
await widget.textSearcher.goToPrevMatch();
_conditionScrollPosition();
}
: null,
icon: const Icon(Icons.arrow_upward),
iconSize: 20,
),
],
),
const SizedBox(height: 4),
Expanded(
child: ListView.builder(
key: Key(searchTextController.text),
controller: scrollController,
itemCount: _listIndexToMatchIndex.length,
itemBuilder: (context, index) {
final matchIndex = _listIndexToMatchIndex[index];
if (matchIndex >= 0 &&
matchIndex < widget.textSearcher.matches.length) {
final match = widget.textSearcher.matches[matchIndex];
return SearchResultTile(
key: ValueKey(index),
match: match,
onTap: () async {
await widget.textSearcher.goToMatchOfIndex(matchIndex);
if (mounted) setState(() {});
},
pageTextStore: pageTextStore,
height: itemHeight,
isCurrent: matchIndex == widget.textSearcher.currentIndex,
);
} else {
return Container(
height: itemHeight,
alignment: Alignment.bottomLeft,
padding: const EdgeInsets.only(bottom: 10),
child: Text(
'Page ${-matchIndex}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
);
}
},
),
),
],
);
}
void _conditionScrollPosition() {
final pos = scrollController.position;
final newPos =
itemHeight * _matchIndexToListIndex[widget.textSearcher.currentIndex!];
if (newPos + itemHeight > pos.pixels + pos.viewportDimension) {
scrollController.animateTo(
newPos + itemHeight - pos.viewportDimension,
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
);
} else if (newPos < pos.pixels) {
scrollController.animateTo(
newPos,
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
);
}
if (mounted) setState(() {});
}
}
class SearchResultTile extends StatefulWidget {
const SearchResultTile({
super.key,
required this.match,
required this.onTap,
required this.pageTextStore,
required this.height,
required this.isCurrent,
});
final PdfTextRangeWithFragments match;
final void Function() onTap;
final PdfPageTextCache pageTextStore;
final double height;
final bool isCurrent;
@override
State<SearchResultTile> createState() => _SearchResultTileState();
}
class _SearchResultTileState extends State<SearchResultTile> {
PdfPageText? pageText;
@override
void initState() {
super.initState();
_load();
}
void _release() {
if (pageText != null) {
widget.pageTextStore.releaseText(pageText!.pageNumber);
}
}
Future<void> _load() async {
_release();
pageText = await widget.pageTextStore.loadText(widget.match.pageNumber);
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_release();
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = Text.rich(createTextSpanForMatch(pageText, widget.match));
return SizedBox(
height: widget.height,
child: Material(
color: widget.isCurrent
? DefaultSelectionStyle.of(context).selectionColor!
: null,
child: InkWell(
onTap: () => widget.onTap(),
child: Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.black12,
width: 0.5,
),
),
),
padding: const EdgeInsets.all(3),
child: text,
),
),
),
);
}
TextSpan createTextSpanForMatch(
PdfPageText? pageText, PdfTextRangeWithFragments match,
{TextStyle? style}) {
style ??= const TextStyle(
fontSize: 14,
);
if (pageText == null) {
return TextSpan(
text: match.fragments.map((f) => f.text).join(),
style: style,
);
}
final fullText = pageText.fullText;
int first = 0;
for (int i = match.fragments.first.index - 1; i >= 0;) {
if (fullText[i] == '\n') {
first = i + 1;
break;
}
i--;
}
int last = fullText.length;
for (int i = match.fragments.last.end; i < fullText.length; i++) {
if (fullText[i] == '\n') {
last = i;
break;
}
}
final header =
fullText.substring(first, match.fragments.first.index + match.start);
final body = fullText.substring(match.fragments.first.index + match.start,
match.fragments.last.index + match.end);
final footer =
fullText.substring(match.fragments.last.index + match.end, last);
return TextSpan(
children: [
TextSpan(text: header),
TextSpan(
text: body,
style: const TextStyle(
backgroundColor: Colors.yellow,
),
),
TextSpan(text: footer),
],
style: style,
);
}
}
/// A helper class to cache loaded page texts.
class PdfPageTextCache {
final PdfTextSearcher textSearcher;
PdfPageTextCache({
required this.textSearcher,
});
final _pageTextRefs = <int, _PdfPageTextRefCount>{};
/// load the text of the given page number.
Future<PdfPageText> loadText(int pageNumber) async {
final ref = _pageTextRefs[pageNumber];
if (ref != null) {
ref.refCount++;
return ref.pageText;
}
return await synchronized(() async {
var ref = _pageTextRefs[pageNumber];
if (ref == null) {
final pageText = await textSearcher.loadText(pageNumber: pageNumber);
ref = _pageTextRefs[pageNumber] = _PdfPageTextRefCount(pageText!);
}
ref.refCount++;
return ref.pageText;
});
}
/// Release the text of the given page number.
void releaseText(int pageNumber) {
final ref = _pageTextRefs[pageNumber]!;
ref.refCount--;
if (ref.refCount == 0) {
_pageTextRefs.remove(pageNumber);
}
}
}
class _PdfPageTextRefCount {
_PdfPageTextRefCount(this.pageText);
final PdfPageText pageText;
int refCount = 0;
}

View file

@ -1,55 +0,0 @@
//
// Super simple thumbnails view
//
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
class ThumbnailsView extends StatelessWidget {
const ThumbnailsView(
{super.key, required this.documentRef, required this.controller});
final PdfDocumentRef? documentRef;
final PdfViewerController? controller;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey,
child: documentRef == null
? null
: PdfDocumentViewBuilder(
documentRef: documentRef!,
builder: (context, document) => ListView.builder(
itemCount: document?.pages.length ?? 0,
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.all(8),
height: 240,
child: Column(
children: [
SizedBox(
height: 220,
child: InkWell(
onTap: () => controller!.goToPage(
pageNumber: index + 1,
anchor: PdfPageAnchor.top,
),
child: PdfPageView(
document: document,
pageNumber: index + 1,
alignment: Alignment.center,
),
),
),
Text(
'${index + 1}',
),
],
),
);
},
),
),
);
}
}

View file

@ -1,389 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/doc_viewer/container/pdf/magic_show_markdown.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:url_launcher/url_launcher.dart';
import 'pdf/magic_bottom_sheet.dart';
import 'pdf/markers_view.dart';
import 'pdf/outline_view.dart';
import 'pdf/password_dialog.dart';
import 'pdf/search_view.dart';
class PDFViewerContainer extends StatefulWidget {
final DocSource source;
const PDFViewerContainer({
super.key,
required this.source,
});
@override
State<PDFViewerContainer> createState() => _PDFViewerContainerState();
}
class _PDFViewerContainerState extends State<PDFViewerContainer> {
final documentRef = ValueNotifier<PdfDocumentRef?>(null);
final controller = PdfViewerController();
final showLeftPane = ValueNotifier<bool>(false);
final outline = ValueNotifier<List<PdfOutlineNode>?>(null);
late final textSearcher = PdfTextSearcher(controller)..addListener(_update);
final _markers = <int, List<Marker>>{};
List<PdfTextRanges>? _textSelections;
void _update() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
textSearcher.removeListener(_update);
textSearcher.dispose();
showLeftPane.dispose();
outline.dispose();
documentRef.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text(
widget.source.title,
style: Theme.of(context).textTheme.bodyLarge,
),
actions: [
IconButton(
icon: const Icon(Icons.list),
onPressed: () {
showLeftPane.value = !showLeftPane.value;
},
),
IconButton(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return Padding(
padding: const EdgeInsets.all(12),
child: search(),
);
},
);
},
icon: const Icon(
Icons.search,
),
)
],
),
floatingActionButton: Row(
children: [
const Spacer(),
IconButton.filledTonal(
icon: const Icon(Icons.zoom_in),
onPressed: () => controller.zoomUp(),
),
IconButton.filledTonal(
icon: const Icon(Icons.zoom_out),
onPressed: () => controller.zoomDown(),
),
const SizedBox(
width: 8,
),
FloatingActionButton.extended(
label: const Text("Magic"),
onPressed: () async {
final result = await showModalBottomSheet(
context: context,
builder: (ctx) {
return MagicBottomSheet(
controller: controller,
);
},
);
if (result == null ||
!context.mounted ||
(result is List) && result.length != 2) {
return;
}
if (result[1] == null || result[0] == null) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) {
return MagicShowMarkdown(
record: result[0] as RecordModel,
pages: result[1] as List<int>,
controller: controller,
fileName: widget.source.title,
);
},
),
);
},
icon: const Icon(
Icons.auto_awesome,
),
)
],
),
body: Row(
children: [
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: ValueListenableBuilder(
valueListenable: showLeftPane,
builder: (context, showLeftPane, child) => SizedBox(
width: showLeftPane ? 300 : 0,
child: child!,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(1, 0, 4, 0),
child: ValueListenableBuilder(
valueListenable: outline,
builder: (context, outline, child) => OutlineView(
outline: outline,
controller: controller,
),
),
),
),
),
Expanded(
child: Stack(
children: [
if (widget.source is FileSource)
PdfViewer.file(
(widget.source as FileSource).filePath,
passwordProvider: () => passwordDialog(context),
controller: controller,
params: params,
),
if (widget.source is URLSource)
PdfViewer.uri(
Uri.parse((widget.source as URLSource).url),
passwordProvider: () => passwordDialog(context),
headers: (widget.source as URLSource).headers,
controller: controller,
params: params,
),
],
),
),
],
),
);
}
Widget search() {
return ValueListenableBuilder(
valueListenable: documentRef,
builder: (context, documentRef, child) => TextSearchView(
textSearcher: textSearcher,
),
);
}
PdfViewerParams get params {
return PdfViewerParams(
enableTextSelection: true,
maxScale: 8,
onViewSizeChanged: (viewSize, oldViewSize, controller) {
if (oldViewSize != null) {
final centerPosition = controller.value.calcPosition(oldViewSize);
final newMatrix = controller.calcMatrixFor(centerPosition);
Future.delayed(
const Duration(milliseconds: 200),
() => controller.goTo(newMatrix),
);
}
},
viewerOverlayBuilder: (context, size, handleLinkTap) => [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
handleLinkTap(details.localPosition);
},
onDoubleTap: () {
if (controller.currentZoom <= 1) {
controller.zoomUp(loop: true);
} else {
controller.zoomDown(
loop: false,
);
}
},
child: IgnorePointer(
child: SizedBox(width: size.width, height: size.height),
),
),
PdfViewerScrollThumb(
controller: controller,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(44, 28),
thumbBuilder: (context, thumbSize, pageNumber, controller) =>
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: Colors.black,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SizedBox(
width: 4,
),
const Icon(
Icons.drag_indicator,
size: 14,
),
Center(
child: Text(
pageNumber.toString(),
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(
width: 8,
),
],
),
),
),
),
PdfViewerScrollThumb(
controller: controller,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(80, 22),
thumbBuilder: (context, thumbSize, pageNumber, controller) =>
ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Container(
color: Colors.black,
child: const Center(
child: Icon(
Icons.drag_indicator_outlined,
size: 18,
),
),
),
),
),
],
loadingBannerBuilder: (context, bytesDownloaded, totalBytes) => Center(
child: CircularProgressIndicator(
value: totalBytes != null ? bytesDownloaded / totalBytes : null,
backgroundColor: Colors.grey,
),
),
linkHandlerParams: PdfLinkHandlerParams(
onLinkTap: (link) {
if (link.url != null) {
navigateToUrl(link.url!);
} else if (link.dest != null) {
controller.goToDest(link.dest);
}
},
),
pagePaintCallbacks: [
textSearcher.pageTextMatchPaintCallback,
_paintMarkers,
],
onDocumentChanged: (document) async {
if (document == null) {
documentRef.value = null;
outline.value = null;
_textSelections = null;
_markers.clear();
}
},
onViewerReady: (document, controller) async {
documentRef.value = controller.documentRef;
outline.value = await document.loadOutline();
},
onTextSelectionChange: (selections) {
_textSelections = selections;
},
);
}
void _paintMarkers(Canvas canvas, Rect pageRect, PdfPage page) {
final markers = _markers[page.pageNumber];
if (markers == null) {
return;
}
for (final marker in markers) {
final paint = Paint()
..color = marker.color.withAlpha(100)
..style = PaintingStyle.fill;
for (final range in marker.ranges.ranges) {
final f = PdfTextRangeWithFragments.fromTextRange(
marker.ranges.pageText,
range.start,
range.end,
);
if (f != null) {
canvas.drawRect(
f.bounds.toRectInPageRect(page: page, pageRect: pageRect),
paint,
);
}
}
}
}
Future<void> navigateToUrl(Uri url) async {
if (await shouldOpenUrl(context, url)) {
await launchUrl(url);
}
}
Future<bool> shouldOpenUrl(BuildContext context, Uri url) async {
final result = await showDialog<bool?>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Navigate to URL?'),
content: SelectionArea(
child: Text.rich(
TextSpan(
children: [
const TextSpan(
text:
'Do you want to navigate to the following location?\n'),
TextSpan(
text: url.toString(),
style: const TextStyle(color: Colors.blue),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Go'),
),
],
);
},
);
return result ?? false;
}
}

View file

@ -1,35 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:photo_view/photo_view.dart';
class PhotoViewer extends StatelessWidget {
final DocSource source;
const PhotoViewer({
super.key,
required this.source,
});
@override
Widget build(BuildContext context) {
ImageProvider provider;
if (source is FileSource) {
provider = FileImage(File((source as FileSource).filePath));
} else if (source is URLSource) {
provider = NetworkImage((source as URLSource).url);
} else {
throw TypeError();
}
return Scaffold(
appBar: AppBar(
title: Text(source.title),
),
body: PhotoView(
imageProvider: provider,
),
);
}
}

View file

@ -1,422 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../utils/load_language.dart';
import '../../connections/types/stremio/stremio_base.types.dart' as types;
import '../../trakt/service/trakt.service.dart';
import '../../watch_history/service/zeee_watch_history.dart';
import '../types/doc_source.dart';
import 'video_viewer/video_viewer_ui.dart';
class VideoViewer extends StatefulWidget {
final DocSource source;
final LibraryItem? meta;
final BaseConnectionService? service;
final String? currentSeason;
final String? library;
const VideoViewer({
super.key,
required this.source,
this.meta,
this.service,
this.currentSeason,
this.library,
});
@override
State<VideoViewer> createState() => _VideoViewerState();
}
class _VideoViewerState extends State<VideoViewer> {
late LibraryItem? meta = widget.meta;
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
Timer? _timer;
late final Player player = Player(
configuration: const PlayerConfiguration(
title: "Madari",
),
);
final Logger _logger = Logger('VideoPlayer');
double get currentProgressInPercentage {
final duration = player.state.duration.inSeconds;
final position = player.state.position.inSeconds;
return duration > 0 ? (position / duration * 100) : 0;
}
bool timeLoaded = false;
Future<types.Meta>? traktProgress;
Future<void> saveWatchHistory() async {
_logger.info('Starting to save watch history...');
final duration = player.state.duration.inSeconds;
if (duration <= 30) {
_logger.info('Video is too short to track.');
return;
}
if (gotFromTraktDuration == false) {
_logger.info(
"Did not start the scrobbling because initially time is not retrieved from the API.",
);
return;
}
final position = player.state.position.inSeconds;
final progress = duration > 0 ? (position / duration * 100) : 0;
if (progress < 0.01) {
_logger.info('No progress to save.');
return;
}
if (meta is types.Meta && TraktService.instance != null) {
try {
if (player.state.playing) {
_logger.info('Starting scrobbling...');
await TraktService.instance!.startScrobbling(
meta: meta as types.Meta,
progress: currentProgressInPercentage,
);
} else {
_logger.info('Stopping scrobbling...');
await TraktService.instance!.stopScrobbling(
meta: meta as types.Meta,
progress: currentProgressInPercentage,
);
}
} catch (e) {
_logger.severe('Error during scrobbling: $e');
TraktService.instance!.debugLogs.add(e.toString());
}
} else {
_logger.warning('Meta is not valid or TraktService is not initialized.');
}
await zeeeWatchHistory!.saveWatchHistory(
history: WatchHistory(
id: _source.id,
progress: progress.round(),
duration: duration.toDouble(),
episode: _source.episode,
season: _source.season,
),
);
_logger.info('Watch history saved successfully.');
}
late final controller = VideoController(
player,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !config.softwareAcceleration,
),
);
late DocSource _source;
bool gotFromTraktDuration = false;
int? traktId;
Future<void> setDurationFromTrakt({
Future<types.Meta>? traktProgress,
}) async {
_logger.info('Setting duration from Trakt...');
try {
if (player.state.duration.inSeconds < 2) {
_logger.info('Duration is too short to set from Trakt.');
return;
}
if (gotFromTraktDuration) {
_logger.info('Duration already set from Trakt.');
return;
}
gotFromTraktDuration = true;
if (!TraktService.isEnabled() ||
(traktProgress ?? this.traktProgress) == null) {
_logger.info(
'Trakt service is not enabled or progress is null. Playing video.');
player.play();
return;
}
final progress = await (traktProgress ?? this.traktProgress);
if (this.meta is! types.Meta) {
_logger.info('Meta is not of type types.Meta.');
return;
}
final meta = (progress ?? this.meta) as types.Meta;
final duration = Duration(
seconds: calculateSecondsFromProgress(
player.state.duration.inSeconds.toDouble(),
meta.currentVideo?.progress ?? meta.progress ?? 0,
),
);
if (duration.inSeconds > 10) {
_logger.info('Seeking to duration: $duration');
await player.seek(duration);
}
await player.play();
_logger.info('Video started playing.');
} catch (e) {
_logger.severe('Error setting duration from Trakt: $e');
await player.play();
}
}
List<StreamSubscription> listener = [];
PlaybackConfig config = getPlaybackConfig();
Future setupVideoThings() async {
_logger.info('Setting up video things...');
if (TraktService.isEnabled()) {
traktProgress = null;
traktProgress = TraktService.instance!.getProgress(
meta as types.Meta,
bypassCache: true,
);
}
_duration = player.stream.duration.listen((item) async {
if (meta is types.Meta) {
setDurationFromTrakt(traktProgress: traktProgress);
}
if (item.inSeconds != 0) {
_logger.info('Duration updated: $item');
await saveWatchHistory();
}
});
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
_logger.info('Periodic save watch history triggered.');
saveWatchHistory();
});
_streamListen = player.stream.playing.listen((playing) {
_logger.info('Playing state changed: $playing');
saveWatchHistory();
});
_logger.info('Loading file...');
return loadFile();
}
destroyVideoThing() async {
_logger.info('Destroying video things...');
timeLoaded = false;
gotFromTraktDuration = false;
traktProgress = null;
for (final item in listener) {
item.cancel();
}
listener = [];
_timer?.cancel();
_streamListen?.cancel();
_duration?.cancel();
if (meta is types.Meta && player.state.duration.inSeconds > 30) {
_logger.info('Stopping scrobbling and clearing cache...');
await TraktService.instance!.stopScrobbling(
meta: meta as types.Meta,
progress: currentProgressInPercentage,
shouldClearCache: true,
traktId: traktId,
);
}
_logger.info('Video things destroyed.');
}
GlobalKey videoKey = GlobalKey();
generateNewKey() {
_logger.info('Generating new key...');
videoKey = GlobalKey();
setState(() {});
}
@override
void initState() {
super.initState();
_logger.info('Initializing VideoViewer...');
_source = widget.source;
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
);
if (player.platform is NativePlayer && !kIsWeb) {
Future.microtask(() async {
_logger.info('Setting network timeout...');
await (player.platform as dynamic).setProperty('network-timeout', '60');
});
}
onVideoChange(
_source,
widget.meta!,
);
_logger.info('VideoViewer initialized.');
}
Future<void> loadFile() async {
_logger.info('Loading file...');
Duration duration = const Duration(seconds: 0);
if (meta is types.Meta && TraktService.isEnabled()) {
_logger.info("Playing video ${(meta as types.Meta).selectedVideoIndex}");
} else {
final item = await zeeeWatchHistory!.getItemWatchHistory(
ids: [
WatchHistoryGetRequest(
id: _source.id,
season: _source.season,
episode: _source.episode,
),
],
);
duration = Duration(
seconds: item.isEmpty
? 0
: calculateSecondsFromProgress(
item.first.duration,
item.first.progress.toDouble(),
),
);
}
_logger.info('Loading file for source: ${_source.id}');
switch (_source.runtimeType) {
case const (FileSource):
if (kIsWeb) {
_logger.info('FileSource is not supported on web.');
return;
}
player.open(
Media(
(_source as FileSource).filePath,
start: duration,
),
play: false,
);
case const (URLSource):
case const (MediaURLSource):
case const (TorrentSource):
player.open(
Media(
(_source as URLSource).url,
httpHeaders: (_source as URLSource).headers,
start: duration,
),
play: false,
);
}
_logger.info('File loaded successfully.');
}
StreamSubscription<bool>? _streamListen;
StreamSubscription<dynamic>? _duration;
@override
void dispose() {
_logger.info('Disposing VideoViewer...');
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: [],
);
destroyVideoThing();
player.dispose();
super.dispose();
_logger.info('VideoViewer disposed.');
}
onVideoChange(DocSource source, LibraryItem item) async {
setState(() {});
await destroyVideoThing();
_logger.info('Changing video source...');
_source = source;
meta = item;
setState(() {});
await setupVideoThings();
setState(() {});
generateNewKey();
_logger.info('Video source changed successfully.');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: VideoViewerUi(
key: videoKey,
controller: controller,
player: player,
config: config,
source: _source,
onLibrarySelect: () {},
service: widget.service,
meta: meta,
onSourceChange: (source, meta) => onVideoChange(source, meta),
),
);
}
}
int calculateSecondsFromProgress(
double duration,
double progressPercentage,
) {
final clampedProgress = progressPercentage.clamp(0.0, 100.0);
final currentSeconds = (duration * (clampedProgress / 100)).round();
return currentSeconds;
}

View file

@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:madari_client/utils/load_language.dart';
import 'package:media_kit/media_kit.dart';
class AudioTrackSelector extends StatefulWidget {
final Player player;
final PlaybackConfig config;
const AudioTrackSelector({
super.key,
required this.player,
required this.config,
});
@override
State<AudioTrackSelector> createState() => _AudioTrackSelectorState();
}
class _AudioTrackSelectorState extends State<AudioTrackSelector> {
List<AudioTrack> audioTracks = [];
Map<String, String> languages = {};
@override
void initState() {
super.initState();
audioTracks = widget.player.state.tracks.audio.where((item) {
return item.id != "auto" && item.id != "no";
}).toList();
loadLanguages(context).then((language) {
if (mounted) {
setState(() {
languages = language;
});
}
});
}
@override
Widget build(BuildContext context) {
return Card(
child: Container(
height: MediaQuery.of(context).size.height * 0.4,
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Select Audio Track',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: ListView.builder(
itemCount: audioTracks.length,
itemBuilder: (context, index) {
final currentItem = audioTracks[index];
final title = currentItem.language ??
currentItem.title ??
currentItem.id;
return ListTile(
title: Text(
languages.containsKey(title) ? languages[title]! : title,
),
selected:
widget.player.state.track.audio.id == currentItem.id,
onTap: () {
widget.player.setAudioTrack(currentItem);
Navigator.pop(context);
},
);
},
),
),
],
),
),
);
}
}

View file

@ -1,209 +0,0 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart';
import '../../../connections/types/stremio/stremio_base.types.dart';
MaterialDesktopVideoControlsThemeData getDesktopControls(
BuildContext context, {
required DocSource source,
required Player player,
Widget? library,
required Function() onSubtitleSelect,
required Function() onAudioSelect,
LibraryItem? meta,
required Function(int index) onVideoChange,
}) {
return MaterialDesktopVideoControlsThemeData(
toggleFullscreenOnDoublePress: true,
displaySeekBar: true,
topButtonBar: [
SafeArea(
child: MaterialDesktopCustomButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.arrow_back),
),
),
SafeArea(
child: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width - 120,
child: Text(
(meta is Meta && meta.currentVideo != null)
? "${meta.name ?? ""} S${meta.currentVideo?.season} E${meta.currentVideo?.episode}"
: source.title.endsWith(".mp4")
? source.title.substring(0, source.title.length - 4)
: source.title,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
const Spacer(),
if (meta is Meta)
if (meta.type == "series")
SeasonSource(
meta: meta,
isMobile: false,
player: player,
onVideoChange: onVideoChange,
),
],
bufferingIndicatorBuilder: source is TorrentSource
? (ctx) {
return TorrentStats(
torrentHash: source.infoHash,
);
}
: null,
playAndPauseOnTap: true,
bottomButtonBar: [
const MaterialDesktopSkipPreviousButton(),
const MaterialDesktopPlayOrPauseButton(),
const MaterialDesktopSkipNextButton(),
const MaterialDesktopVolumeButton(),
const MaterialDesktopPositionIndicator(),
const Spacer(),
MaterialCustomButton(
onPressed: () {
final speeds = [
0.5,
0.75,
1.0,
1.25,
1.5,
1.75,
2.0,
2.5,
3.0,
3.5,
4.0,
4.5,
5.0
];
showCupertinoModalPopup(
context: context,
builder: (ctx) => Card(
child: Container(
height: MediaQuery.of(context).size.height * 0.4,
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Select Playback Speed',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: ListView.builder(
itemCount: speeds.length,
itemBuilder: (context, index) {
final speed = speeds[index];
return ListTile(
title: Text('${speed}x'),
selected: player.state.rate == speed,
onTap: () {
player.setRate(speed);
Navigator.pop(context);
},
);
},
),
),
],
),
),
),
);
},
icon: const Icon(Icons.speed),
),
MaterialDesktopCustomButton(
onPressed: onSubtitleSelect,
icon: const Icon(Icons.subtitles),
),
const SizedBox(
width: 12,
),
MaterialDesktopCustomButton(
onPressed: onAudioSelect,
icon: const Icon(Icons.audiotrack),
),
if (!kIsWeb &&
(Platform.isLinux || Platform.isWindows || Platform.isMacOS))
const AlwaysOnTopButton(),
const MaterialDesktopFullscreenButton(),
],
);
}
class AlwaysOnTopButton extends StatefulWidget {
const AlwaysOnTopButton({super.key});
@override
State<AlwaysOnTopButton> createState() => _AlwaysOnTopButtonState();
}
class _AlwaysOnTopButtonState extends State<AlwaysOnTopButton> {
bool alwaysOnTop = false;
@override
void initState() {
super.initState();
windowManager.isAlwaysOnTop().then((value) {
if (mounted) {
setState(() {
alwaysOnTop = value;
});
}
});
}
@override
Widget build(BuildContext context) {
return Tooltip(
message: "Always on top",
child: MaterialDesktopCustomButton(
onPressed: () async {
if (await windowManager.isAlwaysOnTop()) {
windowManager.setAlwaysOnTop(false);
windowManager.setTitleBarStyle(TitleBarStyle.normal);
setState(() {
alwaysOnTop = false;
});
windowManager.setVisibleOnAllWorkspaces(false);
} else {
windowManager.setAlwaysOnTop(true);
windowManager.setVisibleOnAllWorkspaces(true);
windowManager.setTitleBarStyle(TitleBarStyle.hidden);
setState(() {
alwaysOnTop = true;
});
}
},
icon: Icon(
alwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined,
),
iconSize: 22,
),
);
}
}

View file

@ -1,222 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../connections/types/stremio/stremio_base.types.dart';
class SeasonSource extends StatelessWidget {
final Meta meta;
final bool isMobile;
final Player player;
final Function(int index) onVideoChange;
const SeasonSource({
super.key,
required this.meta,
required this.isMobile,
required this.player,
required this.onVideoChange,
});
@override
Widget build(BuildContext context) {
return MaterialCustomButton(
onPressed: () => onSelectMobile(context),
icon: const Icon(Icons.list_alt),
);
}
onSelectDesktop(BuildContext context) {
showCupertinoDialog(
context: context,
builder: (context) {
return VideoSelectView(
meta: meta,
onVideoChange: onVideoChange,
);
},
);
}
onSelectMobile(BuildContext context) {
showCupertinoDialog(
context: context,
builder: (context) {
return VideoSelectView(
meta: meta,
onVideoChange: onVideoChange,
);
},
);
}
}
class VideoSelectView extends StatefulWidget {
final Meta meta;
final Function(int index) onVideoChange;
const VideoSelectView({
super.key,
required this.meta,
required this.onVideoChange,
});
@override
State<VideoSelectView> createState() => _VideoSelectViewState();
}
class _VideoSelectViewState extends State<VideoSelectView> {
final ScrollController controller = ScrollController();
@override
void initState() {
super.initState();
if (widget.meta.selectedVideoIndex != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
const itemWidth = 240.0 + 16.0;
final offset = widget.meta.selectedVideoIndex! * itemWidth;
controller.jumpTo(offset);
});
}
}
@override
void dispose() {
super.dispose();
controller.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragEnd: (details) {
if (details.primaryVelocity! > 0) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: Colors.black38,
appBar: AppBar(
backgroundColor: Colors.transparent,
title: const Text("Episodes"),
),
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
height: 150,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final video = widget.meta.videos![index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () {
widget.onVideoChange(index);
},
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fill,
image: CachedNetworkImageProvider(
video.thumbnail ??
widget.meta.poster ??
widget.meta.background ??
""),
),
),
child: SizedBox(
width: 240,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment:
CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Colors.black54,
Colors.black38,
],
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
"S${video.season} E${video.episode}",
style: Theme.of(context)
.textTheme
.bodyLarge,
),
Text(
video.name ?? video.title ?? "",
style: Theme.of(context)
.textTheme
.bodyLarge,
),
],
),
),
),
],
),
),
),
),
if (widget.meta.selectedVideoIndex == index)
Positioned(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Colors.black54,
Colors.black38,
],
),
),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Row(
children: [
Text("Playing"),
Icon(Icons.play_arrow),
],
),
),
),
),
],
),
),
);
},
itemCount: (widget.meta.videos ?? []).length,
),
),
],
),
),
),
);
}
}

View file

@ -1,207 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:madari_client/features/connections/service/stremio_connection_service.dart';
import 'package:madari_client/utils/load_language.dart';
import 'package:media_kit/media_kit.dart';
import 'package:shimmer/shimmer.dart';
import '../../../connections/service/base_connection_service.dart';
import '../../../connections/types/stremio/stremio_base.types.dart';
Map<String, List<Subtitle>> externalSubtitlesCache = {};
class SubtitleSelector extends StatefulWidget {
final Player player;
final PlaybackConfig config;
final BaseConnectionService? service;
final LibraryItem? meta;
const SubtitleSelector({
super.key,
required this.player,
required this.config,
required this.service,
this.meta,
});
@override
State<SubtitleSelector> createState() => _SubtitleSelectorState();
}
class _SubtitleSelectorState extends State<SubtitleSelector> {
List<SubtitleTrack> subtitles = [];
Map<String, String> languages = {};
Stream<List<Subtitle>>? externalSubtitles;
late StreamSubscription<List<String>> _subtitles;
@override
void initState() {
super.initState();
if (widget.service is StremioConnectionService && widget.meta is Meta) {
final meta = widget.meta as Meta;
if (externalSubtitlesCache.containsKey(meta.id)) {
externalSubtitles = Stream.value(externalSubtitlesCache[meta.id]!);
} else {
externalSubtitles = (widget.service as StremioConnectionService)
.getSubtitles(meta)
.map((item) {
externalSubtitlesCache[meta.id] = item;
return item;
});
}
}
onPlaybackReady(widget.player.state.tracks);
_subtitles = widget.player.stream.subtitle.listen((item) {
onPlaybackReady(widget.player.state.tracks);
});
loadLanguages(context).then((language) {
if (mounted) {
setState(() {
languages = language;
});
}
});
}
@override
void dispose() {
super.dispose();
_subtitles.cancel();
}
void onPlaybackReady(Tracks tracks) {
setState(() {
subtitles = tracks.subtitle.where((item) {
return item.id != "auto" && item.id != "no";
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(
maxWidth: 520,
),
child: Card(
child: Container(
height: max(MediaQuery.of(context).size.height * 0.4, 400),
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Select Subtitle',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: StreamBuilder<List<Subtitle>>(
stream: externalSubtitles,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Shimmer.fromColors(
baseColor: Colors.black54,
highlightColor: Colors.black54,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return ListTile(
title: Container(
height: 20,
color: Colors.white,
),
);
},
),
);
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return ListView.builder(
itemCount: subtitles.length,
itemBuilder: (context, index) {
final currentItem = subtitles[index];
final title = currentItem.language ??
currentItem.title ??
currentItem.id;
return ListTile(
title: Text(
languages.containsKey(title)
? languages[title]!
: title,
),
selected: widget.player.state.track.subtitle.id ==
currentItem.id,
onTap: () {
widget.player.setSubtitleTrack(currentItem);
Navigator.pop(context);
},
);
},
);
} else {
final externalSubtitlesList = snapshot.data!;
final allSubtitles = [
SubtitleTrack.no(),
...subtitles,
...externalSubtitlesList.map(
(subtitle) {
return SubtitleTrack.uri(
subtitle.url,
language: subtitle.lang,
title:
"${languages[subtitle.lang] ?? subtitle.lang} ${subtitle.id}",
);
},
),
];
return ListView.builder(
itemCount: allSubtitles.length,
itemBuilder: (context, index) {
final currentItem = allSubtitles[index];
final title = currentItem.language ??
currentItem.title ??
currentItem.id;
final isExternal = currentItem.uri;
return ListTile(
title: Text(
"${languages.containsKey(title) ? languages[title]! : title == "no" ? "No subtitle" : title} ${isExternal ? "(External) (${Uri.parse(currentItem.id).host})" : ""}",
),
selected: widget.player.state.track.subtitle.id ==
currentItem.id,
onTap: () async {
await widget.player.setSubtitleTrack(currentItem);
if (context.mounted) Navigator.pop(context);
},
);
},
);
}
},
),
),
],
),
),
),
);
}
}

View file

@ -1,527 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
part 'torrent_stat.g.dart';
class TorrentStats extends StatefulWidget {
final String torrentHash;
const TorrentStats({
super.key,
required this.torrentHash,
});
@override
State<TorrentStats> createState() => _TorrentStatsState();
}
class _TorrentStatsState extends State<TorrentStats> {
late Timer _timer;
TorrentStat? stat;
bool hasOpenOnce = false;
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
@override
void initState() {
super.initState();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) {
getStats();
},
);
}
getStats() async {
final result = await http.get(
Uri.parse(
"http://localhost:64544/torrents/${widget.torrentHash}/stats/v1"),
);
final data = TorrentStat.fromJson(jsonDecode(result.body));
if (mounted) {
setState(() {
stat = data;
hasOpenOnce = true;
});
}
}
@override
void dispose() {
super.dispose();
_timer.cancel();
}
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final isSmallScreen = media.size.width < 600;
if (stat == null) {
return Container(
width: min(media.size.width, 800),
height: min(media.size.height, 180),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: const Center(child: CircularProgressIndicator()),
);
}
return Container(
width: min(media.size.width, 800),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: EdgeInsets.all(isSmallScreen ? 12.0 : 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Status and Progress Row
if (stat?.live != null) ...[
Row(
children: [
// Status Indicator
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: stat?.state == 'Downloading'
? Colors.green
: Colors.grey,
),
),
const SizedBox(width: 8),
Text(
stat?.state ?? 'Loading...',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
const Spacer(),
Text(
'${((stat!.progressBytes / stat!.totalBytes) * 100).toStringAsFixed(1)}%',
style: const TextStyle(
color: Color(0xFFE50914),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
// Progress Bar
LinearProgressIndicator(
value: stat!.progressBytes / stat!.totalBytes,
backgroundColor: Colors.grey[800],
valueColor:
const AlwaysStoppedAnimation<Color>(Color(0xFFE50914)),
),
const SizedBox(height: 12),
// Main Stats Grid
Wrap(
spacing: 16,
runSpacing: 12,
children: [
_buildCompactStat(
Icons.download,
Colors.green,
stat!.live!.downloadSpeed.humanReadable,
),
_buildCompactStat(
Icons.upload,
Colors.blue,
stat!.live!.uploadSpeed.humanReadable,
),
if (stat!.live!.timeRemaining?.humanReadable != null)
_buildCompactStat(
Icons.timer,
Colors.orange,
stat!.live!.timeRemaining!.humanReadable,
),
],
),
const SizedBox(height: 12),
// Advanced Stats
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.all(8),
child: Column(
children: [
_buildAdvancedStatRow(
'Peers',
'${stat!.live!.snapshot.peerStats.live}/${stat!.live!.snapshot.peerStats.seen}',
),
if (stat!.live!.averagePieceDownloadTime?.secs != null)
_buildAdvancedStatRow(
'Avg Download',
'${stat!.live!.averagePieceDownloadTime?.secs}s',
),
_buildAdvancedStatRow(
'Downloaded',
_formatBytes(
stat!.live!.snapshot.downloadedAndCheckedBytes),
),
_buildAdvancedStatRow(
'Uploaded',
_formatBytes(stat!.live!.snapshot.uploadedBytes),
),
],
),
),
],
],
),
),
);
}
Widget _buildCompactStat(IconData icon, Color color, String value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
],
);
}
Widget _buildAdvancedStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
);
}
}
@JsonSerializable()
class TorrentStat {
@JsonKey(name: "state")
final String state;
@JsonKey(name: "file_progress")
final List<int> fileProgress;
@JsonKey(name: "error")
final dynamic error;
@JsonKey(name: "progress_bytes")
final int progressBytes;
@JsonKey(name: "uploaded_bytes")
final int uploadedBytes;
@JsonKey(name: "total_bytes")
final int totalBytes;
@JsonKey(name: "finished")
final bool finished;
@JsonKey(name: "live")
final Live? live;
TorrentStat({
required this.state,
required this.fileProgress,
required this.error,
required this.progressBytes,
required this.uploadedBytes,
required this.totalBytes,
required this.finished,
required this.live,
});
TorrentStat copyWith({
String? state,
List<int>? fileProgress,
dynamic error,
int? progressBytes,
int? uploadedBytes,
int? totalBytes,
bool? finished,
Live? live,
}) =>
TorrentStat(
state: state ?? this.state,
fileProgress: fileProgress ?? this.fileProgress,
error: error ?? this.error,
progressBytes: progressBytes ?? this.progressBytes,
uploadedBytes: uploadedBytes ?? this.uploadedBytes,
totalBytes: totalBytes ?? this.totalBytes,
finished: finished ?? this.finished,
live: live ?? this.live,
);
factory TorrentStat.fromJson(Map<String, dynamic> json) =>
_$TorrentStatFromJson(json);
Map<String, dynamic> toJson() => _$TorrentStatToJson(this);
}
@JsonSerializable()
class Live {
@JsonKey(name: "snapshot")
final Snapshot snapshot;
@JsonKey(name: "average_piece_download_time")
final AveragePieceDownloadTime? averagePieceDownloadTime;
@JsonKey(name: "download_speed")
final LoadSpeed downloadSpeed;
@JsonKey(name: "upload_speed")
final LoadSpeed uploadSpeed;
@JsonKey(name: "time_remaining")
final TimeRemaining? timeRemaining;
Live({
required this.snapshot,
this.averagePieceDownloadTime,
required this.downloadSpeed,
required this.uploadSpeed,
required this.timeRemaining,
});
Live copyWith({
Snapshot? snapshot,
AveragePieceDownloadTime? averagePieceDownloadTime,
LoadSpeed? downloadSpeed,
LoadSpeed? uploadSpeed,
TimeRemaining? timeRemaining,
}) =>
Live(
snapshot: snapshot ?? this.snapshot,
averagePieceDownloadTime:
averagePieceDownloadTime ?? this.averagePieceDownloadTime,
downloadSpeed: downloadSpeed ?? this.downloadSpeed,
uploadSpeed: uploadSpeed ?? this.uploadSpeed,
timeRemaining: timeRemaining ?? this.timeRemaining,
);
factory Live.fromJson(Map<String, dynamic> json) => _$LiveFromJson(json);
Map<String, dynamic> toJson() => _$LiveToJson(this);
}
@JsonSerializable()
class AveragePieceDownloadTime {
@JsonKey(name: "secs")
final int secs;
@JsonKey(name: "nanos")
final int nanos;
AveragePieceDownloadTime({
required this.secs,
required this.nanos,
});
AveragePieceDownloadTime copyWith({
int? secs,
int? nanos,
}) =>
AveragePieceDownloadTime(
secs: secs ?? this.secs,
nanos: nanos ?? this.nanos,
);
factory AveragePieceDownloadTime.fromJson(Map<String, dynamic> json) =>
_$AveragePieceDownloadTimeFromJson(json);
Map<String, dynamic> toJson() => _$AveragePieceDownloadTimeToJson(this);
}
@JsonSerializable()
class LoadSpeed {
@JsonKey(name: "mbps")
final double mbps;
@JsonKey(name: "human_readable")
final String humanReadable;
LoadSpeed({
required this.mbps,
required this.humanReadable,
});
LoadSpeed copyWith({
double? mbps,
String? humanReadable,
}) =>
LoadSpeed(
mbps: mbps ?? this.mbps,
humanReadable: humanReadable ?? this.humanReadable,
);
factory LoadSpeed.fromJson(Map<String, dynamic> json) =>
_$LoadSpeedFromJson(json);
Map<String, dynamic> toJson() => _$LoadSpeedToJson(this);
}
@JsonSerializable()
class Snapshot {
@JsonKey(name: "downloaded_and_checked_bytes")
final int downloadedAndCheckedBytes;
@JsonKey(name: "fetched_bytes")
final int fetchedBytes;
@JsonKey(name: "uploaded_bytes")
final int uploadedBytes;
@JsonKey(name: "downloaded_and_checked_pieces")
final int downloadedAndCheckedPieces;
@JsonKey(name: "total_piece_download_ms")
final int totalPieceDownloadMs;
@JsonKey(name: "peer_stats")
final PeerStats peerStats;
Snapshot({
required this.downloadedAndCheckedBytes,
required this.fetchedBytes,
required this.uploadedBytes,
required this.downloadedAndCheckedPieces,
required this.totalPieceDownloadMs,
required this.peerStats,
});
Snapshot copyWith({
int? downloadedAndCheckedBytes,
int? fetchedBytes,
int? uploadedBytes,
int? downloadedAndCheckedPieces,
int? totalPieceDownloadMs,
PeerStats? peerStats,
}) =>
Snapshot(
downloadedAndCheckedBytes:
downloadedAndCheckedBytes ?? this.downloadedAndCheckedBytes,
fetchedBytes: fetchedBytes ?? this.fetchedBytes,
uploadedBytes: uploadedBytes ?? this.uploadedBytes,
downloadedAndCheckedPieces:
downloadedAndCheckedPieces ?? this.downloadedAndCheckedPieces,
totalPieceDownloadMs: totalPieceDownloadMs ?? this.totalPieceDownloadMs,
peerStats: peerStats ?? this.peerStats,
);
factory Snapshot.fromJson(Map<String, dynamic> json) =>
_$SnapshotFromJson(json);
Map<String, dynamic> toJson() => _$SnapshotToJson(this);
}
@JsonSerializable()
class PeerStats {
@JsonKey(name: "queued")
final int queued;
@JsonKey(name: "connecting")
final int connecting;
@JsonKey(name: "live")
final int live;
@JsonKey(name: "seen")
final int seen;
@JsonKey(name: "dead")
final int dead;
@JsonKey(name: "not_needed")
final int notNeeded;
@JsonKey(name: "steals")
final int steals;
PeerStats({
required this.queued,
required this.connecting,
required this.live,
required this.seen,
required this.dead,
required this.notNeeded,
required this.steals,
});
PeerStats copyWith({
int? queued,
int? connecting,
int? live,
int? seen,
int? dead,
int? notNeeded,
int? steals,
}) =>
PeerStats(
queued: queued ?? this.queued,
connecting: connecting ?? this.connecting,
live: live ?? this.live,
seen: seen ?? this.seen,
dead: dead ?? this.dead,
notNeeded: notNeeded ?? this.notNeeded,
steals: steals ?? this.steals,
);
factory PeerStats.fromJson(Map<String, dynamic> json) =>
_$PeerStatsFromJson(json);
Map<String, dynamic> toJson() => _$PeerStatsToJson(this);
}
@JsonSerializable()
class TimeRemaining {
@JsonKey(name: "duration")
final AveragePieceDownloadTime duration;
@JsonKey(name: "human_readable")
final String humanReadable;
TimeRemaining({
required this.duration,
required this.humanReadable,
});
TimeRemaining copyWith({
AveragePieceDownloadTime? duration,
String? humanReadable,
}) =>
TimeRemaining(
duration: duration ?? this.duration,
humanReadable: humanReadable ?? this.humanReadable,
);
factory TimeRemaining.fromJson(Map<String, dynamic> json) =>
_$TimeRemainingFromJson(json);
Map<String, dynamic> toJson() => _$TimeRemainingToJson(this);
}

View file

@ -1,13 +0,0 @@
import 'package:media_kit/media_kit.dart';
class TraktIntegrationVideo {
Player player;
TraktIntegrationVideo({
required this.player,
});
initState() {}
dispose() {}
}

File diff suppressed because it is too large Load diff

View file

@ -1,276 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:madari_client/utils/load_language.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
class VideoViewerMobile extends StatefulWidget {
final VoidCallback onSubtitleSelect;
final VoidCallback onLibrarySelect;
final Player player;
final DocSource source;
final VideoController controller;
final VoidCallback onAudioSelect;
final PlaybackConfig config;
final GlobalKey<VideoState> videoKey;
final LibraryItem? meta;
final Future<void> Function(int index) onVideoChange;
const VideoViewerMobile({
super.key,
required this.onLibrarySelect,
required this.onSubtitleSelect,
required this.player,
required this.source,
required this.controller,
required this.onAudioSelect,
required this.config,
required this.videoKey,
required this.meta,
required this.onVideoChange,
});
@override
State<VideoViewerMobile> createState() => _VideoViewerMobileState();
}
class _VideoViewerMobileState extends State<VideoViewerMobile> {
final Logger _logger = Logger('_VideoViewerMobileState');
bool isScaled = false;
@override
build(BuildContext context) {
final mobile = _getMobileControls(
context,
onLibrarySelect: widget.onLibrarySelect,
player: widget.player,
source: widget.source,
onSubtitleClick: widget.onSubtitleSelect,
onAudioClick: widget.onAudioSelect,
toggleScale: () {
setState(() {
isScaled = !isScaled;
});
},
);
String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal';
String subtitleStyleColor = widget.config.subtitleColor ?? 'white';
double subtitleSize = widget.config.subtitleSize;
Color hexToColor(String hexColor) {
final hexCode = hexColor.replaceAll('#', '');
try {
return Color(int.parse('0x$hexCode'));
} catch (e) {
return Colors.white;
}
}
FontStyle getFontStyleFromString(String styleName) {
switch (styleName.toLowerCase()) {
case 'italic':
return FontStyle.italic;
case 'normal':
default:
return FontStyle.normal;
}
}
FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName);
return MaterialVideoControlsTheme(
fullscreen: mobile,
normal: mobile,
child: Video(
subtitleViewConfiguration: SubtitleViewConfiguration(
style: TextStyle(
color: hexToColor(subtitleStyleColor),
fontSize: subtitleSize,
fontStyle: currentFontStyle,
fontWeight: FontWeight.bold,
),
),
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
pauseUponEnteringBackgroundMode: true,
key: widget.videoKey,
onExitFullscreen: () async {
await defaultExitNativeFullscreen();
Navigator.of(context).pop();
},
controller: widget.controller,
controls: MaterialVideoControls,
),
);
}
_getMobileControls(
BuildContext context, {
required DocSource source,
required Player player,
required VoidCallback onSubtitleClick,
required VoidCallback onAudioClick,
required VoidCallback toggleScale,
required VoidCallback onLibrarySelect,
}) {
final mediaQuery = MediaQuery.of(context);
final meta = widget.meta;
return MaterialVideoControlsThemeData(
topButtonBar: [
MaterialCustomButton(
onPressed: () {
Navigator.of(
context,
rootNavigator: true,
).pop();
Navigator.of(
context,
rootNavigator: true,
).pop();
},
icon: const Icon(
Icons.arrow_back,
),
),
Text(
meta.toString(),
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(),
if (meta is types.Meta)
if (meta.type == "series")
SeasonSource(
meta: meta,
isMobile: true,
player: player,
onVideoChange: (index) async {
await widget.onVideoChange(index);
setState(() {});
},
),
],
bufferingIndicatorBuilder: (source is TorrentSource)
? (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: TorrentStats(
torrentHash: (source).infoHash,
),
);
}
: null,
brightnessGesture: true,
seekGesture: true,
seekOnDoubleTap: true,
gesturesEnabledWhileControlsVisible: true,
shiftSubtitlesOnControlsVisibilityChange: true,
seekBarMargin: const EdgeInsets.only(bottom: 54),
speedUpOnLongPress: true,
speedUpFactor: 2,
volumeGesture: true,
bottomButtonBar: [
const MaterialPlayOrPauseButton(),
const MaterialPositionIndicator(),
const Spacer(),
MaterialCustomButton(
onPressed: () {
final speeds = [
0.5,
0.75,
1.0,
1.25,
1.5,
1.75,
2.0,
2.25,
2.5,
3.0,
3.25,
3.5,
3.75,
4.0,
4.25,
4.5,
4.75,
5.0
];
showCupertinoModalPopup(
context: context,
builder: (ctx) => Card(
child: Container(
height: MediaQuery.of(context).size.height * 0.4,
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Select Playback Speed',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: ListView.builder(
itemCount: speeds.length,
itemBuilder: (context, index) {
final speed = speeds[index];
return ListTile(
title: Text('${speed}x'),
selected: player.state.rate == speed,
onTap: () {
player.setRate(speed);
Navigator.pop(context);
},
);
},
),
),
],
),
),
),
);
},
icon: const Icon(Icons.speed),
),
MaterialCustomButton(
onPressed: () {
onSubtitleClick();
},
icon: const Icon(Icons.subtitles),
),
MaterialCustomButton(
onPressed: () {
onAudioClick();
},
icon: const Icon(Icons.audio_file),
),
MaterialCustomButton(
onPressed: () {
toggleScale();
},
icon: const Icon(Icons.fit_screen_outlined),
),
],
topButtonBarMargin: EdgeInsets.only(
top: mediaQuery.padding.top,
),
bottomButtonBarMargin: EdgeInsets.only(
bottom: mediaQuery.viewInsets.bottom,
left: 4.0,
right: 4.0,
),
);
}
}

View file

@ -1,308 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/audio_track_selector.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/subtitle_selector.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:madari_client/utils/load_language.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../../utils/tv_detector.dart';
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
import '../../../connections/widget/base/render_stream_list.dart';
import 'desktop_video_player.dart';
class VideoViewerUi extends StatefulWidget {
final VideoController controller;
final Player player;
final PlaybackConfig config;
final DocSource source;
final VoidCallback onLibrarySelect;
final BaseConnectionService? service;
final LibraryItem? meta;
final Function(
DocSource source,
LibraryItem item,
) onSourceChange;
const VideoViewerUi({
super.key,
required this.controller,
required this.player,
required this.config,
required this.source,
required this.onLibrarySelect,
required this.service,
this.meta,
required this.onSourceChange,
});
@override
State<VideoViewerUi> createState() => _VideoViewerUiState();
}
class _VideoViewerUiState extends State<VideoViewerUi> {
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
final Logger _logger = Logger('_VideoViewerUiState');
final List<StreamSubscription> listeners = [];
bool defaultConfigSelected = false;
bool subtitleSelectionHandled = false;
bool audioSelectionHandled = false;
void setDefaultAudioTracks(Tracks tracks) {
if (defaultConfigSelected == true &&
(tracks.audio.length <= 1 || tracks.audio.length <= 1)) {
return;
}
defaultConfigSelected = true;
widget.controller.player.setRate(widget.config.playbackSpeed);
final defaultSubtitle = widget.config.defaultSubtitleTrack;
final defaultAudio = widget.config.defaultAudioTrack;
for (final item in tracks.audio) {
if ((defaultAudio == item.id ||
defaultAudio == item.language ||
defaultAudio == item.title) &&
audioSelectionHandled == false) {
widget.controller.player.setAudioTrack(item);
audioSelectionHandled = true;
break;
}
}
if (widget.config.disableSubtitle) {
for (final item in tracks.subtitle) {
if ((item.id == "no" || item.language == "no" || item.title == "no") &&
subtitleSelectionHandled == false) {
widget.controller.player.setSubtitleTrack(item);
subtitleSelectionHandled = true;
}
}
} else {
for (final item in tracks.subtitle) {
if ((defaultSubtitle == item.id ||
defaultSubtitle == item.language ||
defaultSubtitle == item.title) &&
subtitleSelectionHandled == false) {
subtitleSelectionHandled = true;
widget.controller.player.setSubtitleTrack(item);
break;
}
}
}
}
@override
void initState() {
super.initState();
final listenerComplete = widget.player.stream.completed.listen((completed) {
if (completed) {
widget.onLibrarySelect();
key.currentState?.exitFullscreen();
}
});
listeners.add(listenerComplete);
if (!kIsWeb) {
if (Platform.isAndroid || Platform.isIOS) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
key.currentState?.enterFullscreen();
});
}
}
final listener = widget.player.stream.tracks.listen((tracks) {
if (mounted) {
setDefaultAudioTracks(tracks);
}
});
listeners.add(listener);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
print(widget.meta.toString());
}
@override
void dispose() {
super.dispose();
for (final listener in listeners) {
listener.cancel();
}
}
@override
Widget build(BuildContext context) {
return _buildBody(context);
}
_buildBody(BuildContext context) {
if (DeviceDetector.isTV()) {
return MaterialTvVideoControlsTheme(
fullscreen: const MaterialTvVideoControlsThemeData(),
normal: const MaterialTvVideoControlsThemeData(),
child: Video(
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
controller: widget.controller,
controls: MaterialTvVideoControls,
),
);
}
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return VideoViewerMobile(
onLibrarySelect: widget.onLibrarySelect,
onSubtitleSelect: onSubtitleSelect,
player: widget.player,
source: widget.source,
controller: widget.controller,
onAudioSelect: onAudioSelect,
config: widget.config,
videoKey: key,
meta: widget.meta,
onVideoChange: (index) async {
Navigator.of(context).pop();
widget.player.pause();
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height,
),
builder: (context) {
return Scaffold(
appBar: AppBar(),
body: RenderStreamList(
service: widget.service!,
id: (widget.meta as types.Meta).copyWith(
selectedVideoIndex: index,
),
shouldPop: true,
),
);
},
);
if (result != null) {
widget.onSourceChange(
result,
(widget.meta as types.Meta).copyWith(
selectedVideoIndex: index,
),
);
}
},
);
default:
return _buildDesktop(context);
}
}
_buildDesktop(BuildContext context) {
final desktop = getDesktopControls(
context,
player: widget.player,
source: widget.source,
onAudioSelect: onAudioSelect,
onSubtitleSelect: onSubtitleSelect,
meta: widget.meta,
onVideoChange: (index) async {
Navigator.of(context).pop();
widget.player.pause();
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height,
),
builder: (context) {
return Scaffold(
appBar: AppBar(),
body: RenderStreamList(
service: widget.service!,
id: (widget.meta as types.Meta).copyWith(
selectedVideoIndex: index,
),
shouldPop: true,
),
);
},
);
if (result != null) {
widget.onSourceChange(
result,
(widget.meta as types.Meta).copyWith(
selectedVideoIndex: index,
),
);
}
},
);
return MaterialDesktopVideoControlsTheme(
normal: desktop,
fullscreen: desktop,
child: Video(
key: key,
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
controller: widget.controller,
controls: MaterialDesktopVideoControls,
),
);
}
onAudioSelect() {
_logger.info('Audio track selection triggered.');
showCupertinoModalPopup(
context: context,
builder: (ctx) => AudioTrackSelector(
player: widget.player,
config: widget.config,
),
);
}
onSubtitleSelect() {
_logger.info('Subtitle selection triggered.');
showCupertinoModalPopup(
context: context,
builder: (ctx) => SubtitleSelector(
player: widget.player,
config: widget.config,
service: widget.service,
meta: widget.meta,
),
);
}
}

View file

@ -1,365 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
import 'package:path/path.dart' as path;
import '../utils/get_types.dart';
part 'doc_source.g.dart';
enum DocType { pdf, video, audio, photo, unknown }
sealed class DocSource {
String title;
String id;
String? season;
String? episode;
DocSource({
required this.title,
required this.id,
this.season,
this.episode,
});
DocType getType();
Future<void> init() async {}
void dispose();
}
class IframeSource extends DocSource {
late final String url;
IframeSource({
required this.url,
required super.title,
required super.id,
super.season,
super.episode,
});
@override
void dispose() {}
@override
DocType getType() {
throw UnimplementedError();
}
}
class ProgressStatus extends DocSource {
final double? percentage;
final String? progressText;
@override
DocType getType() {
return DocType.unknown;
}
ProgressStatus({
required super.id,
required super.title,
this.progressText,
this.percentage,
});
@override
void dispose() {}
}
class URLSource extends DocSource {
String url;
String? fileName;
Map<String, String> headers = {};
URLSource({
required super.title,
required this.url,
required super.id,
super.season,
super.episode,
this.fileName,
this.headers = const {},
});
@override
DocType getType() {
String cleanUrl = (url).split('?').first;
String extension = (fileName ?? cleanUrl).split('.').last.toLowerCase();
return getTypeFromExtension(extension.trim());
}
@override
void dispose() {}
}
class MediaURLSource extends URLSource {
MediaURLSource({required super.title, required super.url, required super.id});
@override
DocType getType() {
return DocType.video;
}
}
class TorrentSource extends URLSource {
final String infoHash;
@override
final String fileName;
final List<String>? trackers;
bool disposed = false;
TorrentSource({
required super.id,
required super.title,
required this.infoHash,
required this.fileName,
super.season,
super.episode,
this.trackers,
super.url = "",
});
@override
DocType getType() {
String extension = fileName.split('.').last.toLowerCase();
return getTypeFromExtension(extension);
}
@override
Future<void> init() async {
final trackers = [
"udp://47.ip-51-68-199.eu:6969/announce",
"udp://9.rarbg.me:2940",
"udp://9.rarbg.to:2820",
"udp://exodus.desync.com:6969/announce",
"udp://explodie.org:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://open.stealth.si:80/announce",
"udp://opentor.org:2710/announce",
"udp://opentracker.i2p.rocks:6969/announce",
"udp://retracker.lanta-net.ru:2710/announce",
"udp://tracker.cyberia.is:6969/announce",
"udp://tracker.dler.org:6969/announce",
"udp://tracker.ds.is:6969/announce",
"udp://tracker.internetwarriors.net:1337",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.opentrackr.org:1337/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://valakas.rollo.dnsabr.com:2710/announce",
"udp://www.torrent.eu.org:451/announce"
];
final value1 =
await http.get(Uri.parse("http://localhost:64544/torrents/$infoHash"));
if (jsonDecode(value1.body)["error_kind"] == "torrent_not_found") {
await http.post(
Uri.parse("http://localhost:64544/torrents?overwrite=true"),
body: addTrackersToMagnet(
"magnet:?xt=urn:btih:${Uri.encodeComponent(infoHash)}",
trackers,
),
);
} else {
await http.post(
Uri.parse("http://localhost:64544/torrents/$infoHash/start"),
);
}
final value = await http.get(
Uri.parse("http://localhost:64544/torrents/$infoHash"),
);
final obj = jsonDecode(
value.body,
);
final objTorrent = TorrentInfoObject.fromJson(obj);
for (final (index, file) in objTorrent.files.indexed) {
if (path.basename(file.name) == fileName) {
url = "http://localhost:64544/torrents/$infoHash/stream/$index";
await http.post(
Uri.parse(
"http://localhost:64544/torrents/$infoHash/update_only_files"),
headers: {
"Content-Type": "application/json",
},
body: jsonEncode(
{
"only_files": [index]
},
),
);
break;
}
}
if (url == "") throw AssertionError();
return super.init();
}
@override
void dispose() {
super.dispose();
disposed = true;
http
.post(
Uri.parse(
"http://localhost:64544/torrents/$infoHash/pause",
),
)
.then(
(docs) {
if (kDebugMode) {
print(docs.statusCode);
print("Stopped downloading file");
}
},
);
}
Future<Uint8List?> readFirst1MBFromUrl(String url) async {
final client = http.Client();
try {
int attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
if (kDebugMode) {
print("Reading $attempts at $url");
}
if (disposed) {
break;
}
try {
final request = http.Request('GET', Uri.parse(url));
request.headers['range'] = 'bytes=0-${1024 * 1}';
final streamedResponse = await client.send(request);
// Check if the response is successful
if (streamedResponse.statusCode >= 200 &&
streamedResponse.statusCode < 300) {
final bytes =
await streamedResponse.stream.take(1024 * 1024).fold<List<int>>(
[],
(previous, element) => previous..addAll(element),
);
return Uint8List.fromList(bytes);
}
throw HttpException(
'Failed with status: ${streamedResponse.statusCode}');
} catch (e) {
attempts++;
if (attempts >= maxAttempts) {
throw Exception('Failed after $maxAttempts attempts: $e');
}
if (kDebugMode) {
print(e);
}
await Future.delayed(
Duration(milliseconds: pow(2, attempts).toInt() * 100),
);
}
}
throw Exception('Unexpected error');
} finally {
client.close();
}
}
}
@JsonSerializable()
class TorrentInfoObject {
final List<TorrentFile> files;
TorrentInfoObject({
required this.files,
});
factory TorrentInfoObject.fromJson(Map<String, dynamic> json) =>
_$TorrentInfoObjectFromJson(json);
Map<String, dynamic> toJson() => _$TorrentInfoObjectToJson(this);
}
@JsonSerializable()
class TorrentFile {
final String name;
TorrentFile({
required this.name,
});
factory TorrentFile.fromJson(Map<String, dynamic> json) =>
_$TorrentFileFromJson(json);
Map<String, dynamic> toJson() => _$TorrentFileToJson(this);
}
String escapeRegex(String input) {
const specialChars = r'[.*+?^${}()|[\]\\]';
return input.replaceAllMapped(
RegExp(specialChars), (Match match) => '\\${match.group(0)}');
}
class FileSource extends DocSource {
String filePath;
FileSource({
required super.title,
required this.filePath,
required super.id,
});
@override
DocType getType() {
String extension = filePath.split('.').last.toLowerCase();
return getTypeFromExtension(extension);
}
@override
void dispose() {}
}
String addTrackersToMagnet(String magnetLink, List<String> trackers) {
final uri = Uri.parse(magnetLink);
if (!uri.scheme.contains("magnet")) {
throw ArgumentError("Invalid magnet link");
}
final existingTrackers = uri.queryParametersAll['tr'] ?? [];
final updatedTrackers = [...existingTrackers, ...trackers];
final updatedQueryParameters =
Map<String, List<String>>.from(uri.queryParametersAll)
..['tr'] = updatedTrackers;
final updatedUri = uri.replace(queryParameters: updatedQueryParameters);
return updatedUri.toString();
}

View file

@ -1,50 +0,0 @@
import '../types/doc_source.dart';
DocType getTypeFromExtension(String extension) {
switch (extension) {
// PDF extensions
case 'pdf':
return DocType.pdf;
// Video extensions
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
case 'mkv':
case 'webm':
case 'flv':
case 'm4v':
case 'mpg':
case 'mpeg':
case '3gp':
return DocType.video;
// Audio extensions
case 'mp3':
case 'wav':
case 'flac':
case 'aac':
case 'm4a':
case 'wma':
case 'ogg':
case 'opus':
return DocType.audio;
// Photo extensions
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'bmp':
case 'tiff':
case 'webp':
case 'svg':
case 'heic':
case 'raw':
return DocType.photo;
default:
return DocType.unknown;
}
}

View file

@ -1,324 +0,0 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
class DownloadDialog extends StatefulWidget {
const DownloadDialog({super.key});
@override
State<DownloadDialog> createState() => _DownloadDialogState();
}
class _DownloadDialogState extends State<DownloadDialog> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
final _nameController = TextEditingController();
bool _isValidating = false;
String? _validationError;
Map<String, dynamic>? _fileInfo;
Future<void> _startDownload() async {
if (!_formKey.currentState!.validate()) return;
final task = DownloadTask(
url: _urlController.text,
filename: '${_nameController.text}.mp4',
directory: 'downloads',
updates: Updates.statusAndProgress,
allowPause: true,
displayName: _nameController.text,
);
await FileDownloader().enqueue(task);
if (mounted) Navigator.of(context).pop();
}
Future<void> _validateUrl() async {
if (_urlController.text.isEmpty) return;
setState(() {
_isValidating = true;
_validationError = null;
_fileInfo = null;
});
try {
final uri = Uri.parse(_urlController.text);
if (!await canLaunchUrl(uri)) {
throw Exception('Invalid URL');
}
// Make a HEAD request to get file information
final response = await http.head(uri);
if (response.statusCode != 200) {
throw Exception('Could not access file');
}
// Get file size from headers
final contentLength = response.headers['content-length'];
final fileSize = contentLength != null
? _formatFileSize(int.parse(contentLength))
: 'Unknown size';
// Get content type from headers
final contentType = response.headers['content-type'] ?? 'Unknown type';
// Extract filename from URL or Content-Disposition header
String fileName = '';
final disposition = response.headers['content-disposition'];
if (disposition != null && disposition.contains('filename=')) {
fileName = disposition.split('filename=')[1].replaceAll('"', '');
} else {
fileName = path.basename(uri.path);
}
// Remove extension from filename for display name
_nameController.text = path.basenameWithoutExtension(fileName);
_fileInfo = {
'size': fileSize,
'type': contentType,
'filename': fileName,
};
} catch (e) {
_validationError = 'Invalid URL: ${e.toString()}';
} finally {
setState(() {
_isValidating = false;
});
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: MediaQuery.of(context).size.width < 600 ? double.infinity : 500,
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const Row(
children: [
Icon(
Icons.download_rounded,
size: 30,
),
SizedBox(width: 12),
Text(
'Add New Download',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
// URL Input Field
TextFormField(
controller: _urlController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'URL',
hintText: 'Paste your download URL here',
filled: true,
prefixIcon: const Icon(Icons.link, color: Colors.grey),
suffixIcon: _isValidating
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.red),
),
)
: IconButton(
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
onPressed: _validateUrl,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a URL';
}
return null;
},
keyboardType: TextInputType.url,
onChanged: (_) => setState(() => _fileInfo = null),
),
// Validation Error
if (_validationError != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
_validationError!,
style: const TextStyle(
color: Colors.red,
fontSize: 12,
),
),
],
),
],
// File Information
if (_fileInfo != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[850],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[700]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoRow(Icons.folder_outlined, 'File Size:',
_fileInfo?['size'] ?? ''),
const SizedBox(height: 12),
_infoRow(Icons.description_outlined, 'Type:',
_fileInfo?['type'] ?? ''),
const SizedBox(height: 12),
_infoRow(Icons.insert_drive_file_outlined, 'File:',
_fileInfo?['filename'] ?? ''),
],
),
),
// Display Name Input
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Display Name',
hintText: 'Enter a name for this download',
filled: true,
prefixIcon: Icon(Icons.edit, color: Colors.grey),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a name';
}
return null;
},
),
],
// Action Buttons
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
),
child: Text(
'Cancel',
style: TextStyle(color: Colors.grey[400]),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _fileInfo != null ? _startDownload : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
disabledBackgroundColor: Colors.grey[700],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_rounded, size: 20),
SizedBox(width: 8),
Text(
'Download',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
],
),
),
),
);
}
Widget _infoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, color: Colors.grey[400], size: 20),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: Colors.grey[400]),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
@override
void dispose() {
_urlController.dispose();
_nameController.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,192 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import '../service/download_service.dart';
class DownloadsPage extends StatefulWidget {
const DownloadsPage({super.key});
@override
State<DownloadsPage> createState() => _DownloadsPageState();
}
class _DownloadsPageState extends State<DownloadsPage> {
final _urlController = TextEditingController();
final _filenameController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isSubmitting = false;
@override
void dispose() {
_urlController.dispose();
_filenameController.dispose();
super.dispose();
}
Future<void> _startDownload() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
await DownloadService.instance.startDownload(
_urlController.text,
_filenameController.text,
);
if (mounted) {
_urlController.clear();
_filenameController.clear();
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text("Downloads"),
),
body: Center(
child: const Text("Not implemented") ??
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'URL',
border: OutlineInputBorder(),
),
validator: (value) {
if (value?.isEmpty ?? true)
return 'URL is required';
if (!(Uri.tryParse(value!)?.hasAbsolutePath ??
true)) {
return 'Invalid URL';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _filenameController,
decoration: const InputDecoration(
labelText: 'Filename',
border: OutlineInputBorder(),
),
validator: (value) {
if (value?.isEmpty ?? true)
return 'Filename is required';
return null;
},
),
const SizedBox(height: 16),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _isSubmitting ? null : _startDownload,
child: _isSubmitting
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(),
)
: const Text('Start Download'),
),
),
],
),
),
const SizedBox(height: 32),
StreamBuilder<List<TaskRecord>>(
stream: DownloadService.instance.records,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final records = snapshot.data!;
if (records.isEmpty) {
return Center(
child: Text(
'No downloads yet',
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: records.length,
itemBuilder: (context, index) {
final record = records[index];
final progress =
(record.progress * 100).toStringAsFixed(1);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(record.task.filename),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (record.status == TaskStatus.running)
LinearProgressIndicator(
value: record.progress),
const SizedBox(height: 4),
Text('Status: ${record.status.name}'),
if (record.status == TaskStatus.running)
Text('Progress: $progress%'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (record.status == TaskStatus.running) ...[
IconButton(
icon: const Icon(Icons.pause),
onPressed: () => DownloadService.instance
.pauseDownload(record.taskId),
),
] else if (record.status ==
TaskStatus.paused) ...[
IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: () => DownloadService.instance
.resumeDownload(record.taskId),
),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: () => DownloadService.instance
.cancelDownload(record.taskId),
),
],
),
),
);
},
);
},
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,82 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
class DownloadService {
static final DownloadService _instance = DownloadService._internal();
static DownloadService get instance => _instance;
final StreamController<List<TaskRecord>> _recordsController =
StreamController<List<TaskRecord>>.broadcast();
Stream<List<TaskRecord>> get records => _recordsController.stream;
final Map<String, DownloadTask> _tasks = {};
DownloadService._internal() {
_init();
}
Future<void> _init() async {
await FileDownloader().trackTasks();
FileDownloader().configureNotification(
running: const TaskNotification('Downloading', 'File: {filename}'),
complete: const TaskNotification('Download Complete', 'File: {filename}'),
error: const TaskNotification('Download Failed', 'File: {filename}'),
progressBar: true,
);
FileDownloader().updates.listen((update) async {
await _updateRecords();
});
}
Future<void> _updateRecords() async {
final records = await FileDownloader().database.allRecords();
_recordsController.add(records);
}
Future<bool> startDownload(String url, String filename) async {
final task = DownloadTask(
url: url,
filename: filename,
updates: Updates.statusAndProgress,
allowPause: true,
retries: 3,
);
_tasks[task.taskId] = task;
final success = await FileDownloader().enqueue(task);
await _updateRecords();
return success;
}
Future<void> pauseDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
await FileDownloader().pause(task);
await _updateRecords();
}
}
Future<void> resumeDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
await FileDownloader().resume(task);
await _updateRecords();
}
}
Future<void> cancelDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
await FileDownloader().cancelTaskWithId(taskId);
_tasks.remove(taskId);
await _updateRecords();
}
}
Future<void> dispose() async {
await _recordsController.close();
}
}

View file

@ -1,72 +0,0 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
class DownloadService {
static final DownloadService _instance = DownloadService._internal();
static DownloadService get instance => _instance;
final FileDownloader _downloader = FileDownloader();
final _updateController = StreamController<TaskUpdate>.broadcast();
Stream<TaskUpdate> get updates => _updateController.stream;
StreamSubscription? _downloadSubscription;
DownloadService._internal();
Future<void> initialize() async {
await _downloader.trackTasks();
// Subscribe to FileDownloader updates and broadcast them
_downloadSubscription = _downloader.updates.listen(
(update) => _updateController.add(update),
onError: (error) => _updateController.addError(error),
);
FileDownloader().configureNotification(
running: const TaskNotification('Downloading', 'File: {filename}'),
complete: const TaskNotification('Download finished', 'File: {filename}'),
progressBar: true,
);
}
void dispose() {
_downloadSubscription?.cancel();
_updateController.close();
}
Future<List<TaskRecord>> getAllDownloads() async {
return await _downloader.database.allRecords();
}
Future<TaskRecord?> getById(String taskId) async {
return await _downloader.database.recordForId(taskId);
}
Future<void> pauseDownload(DownloadTask task) async {
await _downloader.pause(task);
}
Future<void> resumeDownload(DownloadTask task) async {
await _downloader.resume(task);
}
Future<void> deleteDownload(String taskId) async {
await _downloader.database.deleteRecordWithId(taskId);
}
Future<void> startDownload(DownloadTask task) async {
const permissionType = PermissionType.notifications;
var status = await FileDownloader().permissions.status(permissionType);
if (status != PermissionStatus.granted) {
if (await FileDownloader()
.permissions
.shouldShowRationale(permissionType)) {}
status = await FileDownloader().permissions.request(permissionType);
debugPrint('Permission for $permissionType was $status');
}
await _downloader.enqueue(task);
}
}

View file

@ -0,0 +1,348 @@
import 'package:cached_query/cached_query.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart';
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
import 'package:madari_client/utils/array-extension.dart';
import '../../widgetter/plugins/stremio/widgets/catalog_grid_full.dart';
import '../../widgetter/plugins/stremio/widgets/error_card.dart';
final _logger = Logger('ExploreAddon');
class ExploreAddon extends StatefulWidget {
final List<StremioManifest> data;
const ExploreAddon({
super.key,
required this.data,
});
@override
State<ExploreAddon> createState() => _ExploreAddonState();
}
class _ExploreAddonState extends State<ExploreAddon> {
String? selectedType;
String? selectedId;
String? selectedGenre;
StremioManifest? selectedAddon;
static const int pageSize = 50;
final service = StremioAddonService.instance;
InfiniteQuery<List<Meta>, int>? _query;
@override
void initState() {
super.initState();
setFirstThing();
setOptionValues();
setQuery();
}
setQuery() {
_query = buildQuery();
setState(() {});
}
String get queryKey {
return "explorer_page_${selectedType}_${selectedId}_$selectedGenre";
}
InfiniteQuery<List<Meta>, int> buildQuery() {
return InfiniteQuery(
key: queryKey,
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: const Duration(hours: 8),
),
getNextArg: (state) {
final lastPage = state.lastPage;
if (lastPage == null) return 1;
if (lastPage.length < pageSize) return null;
return state.length + 1;
},
queryFn: (page) async {
_logger.info('Fetching catalog for page: $page');
try {
final addonManifest = await service
.validateManifest(selectedAddon!.manifestUrl!)
.queryFn();
List<ConnectionFilterItem> items = [];
if (selectedGenre != null) {
items.add(
ConnectionFilterItem(
title: "genre",
value: selectedGenre,
),
);
}
return service.getCatalog(
addonManifest,
selectedType!,
selectedId!,
page - 1,
items,
);
} catch (e, stack) {
_logger.severe('Error fetching catalog: $e', e, stack);
throw Exception('Failed to fetch catalog');
}
},
);
}
setFirstThing() {
final Set<String> genres = {};
StremioManifest? selectedAddon;
for (final item in widget.data) {
for (final value in item.catalogs!) {
selectedType ??= value.type;
selectedAddon ??= item;
if (selectedType == value.type) {
selectedId ??= value.id;
selectedAddon = item;
}
if (selectedType == value.type && selectedId == value.id) {
final extra = value.extra?.firstWhereOrNull((extra) {
return extra.name == "genre";
});
if (extra != null && extra.options?.isNotEmpty == true) {
for (final option in extra.options!) {
selectedGenre ??= option;
selectedAddon = item;
genres.add(option);
}
}
}
}
}
this.selectedAddon = selectedAddon;
this.genres = genres.toList();
}
setOptionValues() {
final Set<String> types = {};
for (final item in widget.data) {
for (final value in item.catalogs!) {
if (value.type != selectedType) {
continue;
}
types.add(value.id);
}
}
categories = types.toList();
}
List<String> get types {
final Set<String> allTypes = {};
for (final item in widget.data) {
if (item.catalogs == null) {
continue;
}
for (final value in item.catalogs!) {
allTypes.add(value.type);
}
}
return allTypes.toList();
}
List<String> categories = [];
List<String> genres = [];
void _showSelectionSheet(
List<String> items,
String title,
String current,
Function(String) onSelect, {
List<String> resetTypes = const [],
}) {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
),
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(
items[index]
.replaceAll(".", " ")
.split(" ")
.map((item) => item.capitalize)
.join(" "),
),
selected: items[index] == current,
trailing: items[index] == current
? Icon(
Icons.check_circle,
color: Theme.of(context).highlightColor,
)
: null,
onTap: () {
onSelect(items[index]);
if (resetTypes.contains('categories')) {
selectedId = null;
}
if (resetTypes.contains('genres')) {
selectedGenre = null;
}
setFirstThing();
setOptionValues();
setQuery();
Navigator.pop(context);
},
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (selectedId == null || selectedType == null) {
return const Scaffold(
body: ErrorCard(error: "No addon with support for catalog"),
);
}
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
FilterChip(
selected: true,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
selectedType!
.replaceAll(".", " ")
.split(" ")
.map((item) => item.capitalize)
.join(" "),
),
const Icon(Icons.arrow_drop_down, size: 18)
],
),
onSelected: (_) => _showSelectionSheet(
types,
'Select Type',
selectedType!,
(value) => setState(() => selectedType = value),
resetTypes: [
'categories',
'genres',
],
),
),
const SizedBox(width: 8),
FilterChip(
selected: true,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
selectedId!
.replaceAll(".", " ")
.split(" ")
.map((item) => item.capitalize)
.join(" "),
),
const Icon(Icons.arrow_drop_down, size: 18)
],
),
onSelected: (_) => _showSelectionSheet(
categories,
'Select Category',
selectedId!,
(value) => setState(() => selectedId = value),
resetTypes: [
'genres',
],
),
),
const SizedBox(width: 8),
if (genres.isNotEmpty)
FilterChip(
selected: selectedGenre != null,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
(selectedGenre ?? 'Genre')
.replaceAll(".", " ")
.split(" ")
.map((item) => item.capitalize)
.join(" "),
),
const Icon(Icons.arrow_drop_down, size: 18)
],
),
onSelected: (_) => _showSelectionSheet(
genres,
'Select Genre',
selectedGenre ?? '',
(value) => setState(() => selectedGenre = value),
),
),
],
),
),
Expanded(
child: _query != null
? CatalogFullView(
initialItems: const [],
prefix: "explore",
query: buildQuery(),
key: ValueKey(queryKey),
)
: const Center(
child: CircularProgressIndicator(),
),
),
],
),
);
}
}

View file

@ -0,0 +1,74 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
import 'package:madari_client/features/widgetter/plugins/stremio/widgets/error_card.dart';
import '../../streamio_addons/models/stremio_base_types.dart';
import '../containers/explore_addon.dart';
class ExplorePage extends StatefulWidget {
const ExplorePage({
super.key,
});
@override
State<ExplorePage> createState() => _ExplorePageState();
}
class _ExplorePageState extends State<ExplorePage> {
late Query<List<StremioManifest>> _query;
@override
void initState() {
super.initState();
setQuery();
}
void setQuery() {
_query = Query(
key: "addons",
queryFn: () async {
final result = StremioAddonService.instance;
return await result
.getInstalledAddons(
enabledOnly: true,
)
.queryFn();
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const Icon(Icons.explore_outlined),
title: const Text("Explore"),
),
body: QueryBuilder(
builder: (context, state) {
if (state.status == QueryStatus.loading || state.data == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.data?.isEmpty == true) {
return const ErrorCard(
error: "No Addons found",
title: "No addons are configured",
);
}
return ExploreAddon(
data: state.data!,
);
},
query: _query,
),
);
}
}

View file

@ -1,169 +0,0 @@
import 'package:flutter/material.dart';
class FileItem {
final String name;
final bool isDirectory;
final String? path;
FileItem({
required this.name,
required this.isDirectory,
this.path,
});
}
class FilesManagerContainer extends StatefulWidget {
final Future<List<FileItem>> Function(String? path) onLoadFiles;
final Future<void> Function(String path, String name) onCreateFolder;
const FilesManagerContainer({
super.key,
required this.onLoadFiles,
required this.onCreateFolder,
});
@override
State<FilesManagerContainer> createState() => _FilesManagerContainerState();
}
class _FilesManagerContainerState extends State<FilesManagerContainer> {
late Future<List<FileItem>> _filesFuture;
String _currentPath = '';
final List<String> _navigationStack = [];
@override
void initState() {
super.initState();
_loadFiles();
}
void _loadFiles() {
_filesFuture = widget.onLoadFiles(_currentPath);
}
void _navigateToFolder(String path) {
setState(() {
_navigationStack.add(_currentPath);
_currentPath = path;
_loadFiles();
});
}
bool _navigateBack() {
if (_navigationStack.isNotEmpty) {
setState(() {
_currentPath = _navigationStack.removeLast();
_loadFiles();
});
return true;
}
return false;
}
Future<void> _createFolder() async {
final controller = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create New Folder'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Folder name',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Create'),
),
],
),
);
if (result == true && controller.text.isNotEmpty && mounted) {
try {
await widget.onCreateFolder(_currentPath, controller.text);
setState(() {
_loadFiles();
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to create folder: $e')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return !_navigateBack(); // Return true to exit app, false to stay
},
child: Scaffold(
appBar: AppBar(
leading: _navigationStack.isNotEmpty
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _navigateBack,
)
: null,
title: Text(_currentPath.isEmpty ? 'Files' : _currentPath),
actions: [
IconButton(
icon: const Icon(Icons.create_new_folder),
onPressed: _createFolder,
),
],
),
body: FutureBuilder<List<FileItem>>(
future: _filesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
final files = snapshot.data ?? [];
if (files.isEmpty) {
return const Center(
child: Text('No files found'),
);
}
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
return ListTile(
leading: Icon(
file.isDirectory ? Icons.folder : Icons.insert_drive_file,
color: file.isDirectory ? Colors.amber : Colors.blue,
),
title: Text(file.name),
onTap: file.isDirectory
? () => _navigateToFolder(file.path!)
: null,
);
},
);
},
),
),
);
}
}

View file

@ -1,526 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import 'package:madari_client/features/connection/services/stremio_service.dart';
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/engine.dart';
import '../../settings/types/connection.dart';
class CreateConnectionStep extends StatefulWidget {
final void Function(Connection connection) onConnectionComplete;
const CreateConnectionStep({
super.key,
required this.onConnectionComplete,
});
@override
State<CreateConnectionStep> createState() => _CreateConnectionStepState();
}
class _CreateConnectionStepState extends State<CreateConnectionStep> {
final PocketBase pb = AppEngine.engine.pb;
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
final _nameController = TextEditingController(
text: "Stremio Addons",
);
Connection? _existingConnection;
bool _isLoading = false;
String? _errorMessage;
final List<Map<String, dynamic>> _addons = [];
@override
void initState() {
super.initState();
loadExistingConnection();
}
loadExistingConnection() async {
try {
final existingConnection =
await pb.collection("connection").getFirstListItem(
"type.type = 'stremio_addons'",
);
final connection = Connection.fromRecord(existingConnection);
_nameController.text = connection.title;
final config = connection.config;
if (config['addons'] != null) {
for (var url in config['addons']) {
try {
await _validateAddonUrl(url);
} catch (e) {
print("Failed to load addon");
}
}
}
_existingConnection = connection;
} catch (e) {
if (e is! ClientException) {
rethrow;
}
}
}
Future<void> _validateAddonUrl(String url_) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final url = url_.replaceFirst("stremio://", "https://");
try {
final response = await http.get(
Uri.parse(
url.replaceFirst("stremio://", "https://"),
),
);
if (response.statusCode == 200) {
final manifest = json.decode(response.body);
final _manifest = StremioManifest.fromJson(manifest);
if (manifest['name'] == null || manifest['id'] == null) {
throw 'Invalid addon manifest';
}
if (_addons.any((addon) => addon['url'] == url)) {
throw 'Addon already added to the list';
}
List<String> supportedTypes = [];
_manifest.resources?.forEach((item) {
supportedTypes.add(item.name);
});
setState(() {
_addons.add({
'name': _manifest.name,
'icon': manifest['logo'] ?? manifest['icon'],
'url': url,
'addons': manifest,
'manifestParsed': _manifest,
'types': supportedTypes,
});
_urlController.clear();
});
} else {
throw 'Failed to fetch addon manifest';
}
} catch (e) {
setState(() {
_errorMessage = 'Invalid addon URL: ${e.toString()}';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<bool> showAddonWarningDialog(
BuildContext context, {
required bool isMeta,
required bool isAddon,
}) async {
bool continueAnyway = false;
if (isMeta && isAddon) {
return true;
}
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Warning!'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMeta || !isAddon)
const Text(
'You are missing the following addons:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(
height: 4,
),
if (!isMeta) const Text('🔴 Meta Addon'),
if (!isAddon) const Text('🔴 Streaming Addon'),
const SizedBox(height: 10),
const Text(
'Continuing without these addons may limit functionality. Are you sure you want to proceed?',
style: TextStyle(color: Colors.red),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () {
// User chooses to continue anyway
Navigator.of(context).pop();
continueAnyway = true;
},
child: const Text('CONTINUE ANYWAY'),
),
ElevatedButton(
onPressed: () {
// User chooses to add addon
Navigator.of(context).pop();
continueAnyway = false;
},
child: const Text('ADD ADDON'),
),
],
);
},
);
return continueAnyway;
}
Future<void> _saveConnection() async {
if (!_formKey.currentState!.validate() || _addons.isEmpty) return;
bool hasMeta = false;
bool hasStream = false;
for (final item in _addons) {
final manifest = item['manifestParsed'] as StremioManifest;
if (manifest.resources == null) {
continue;
}
for (final resource in manifest.resources!) {
if (resource.name == "meta") {
hasMeta = true;
}
if (resource.name == "stream") {
hasStream = true;
}
}
}
final result = await showAddonWarningDialog(
context,
isAddon: hasStream,
isMeta: hasMeta,
);
if (!result) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final connectionType =
await pb.collection("connection_type").getFirstListItem(
"type = \"stremio_addons\"",
);
final body = {
'title': _nameController.text,
'user': pb.authStore.record!.id,
'type': connectionType.id,
'config': jsonEncode({
'addons': _addons.map((item) => item['url']).toList(),
}),
};
if (_existingConnection != null) {
// Update existing connection
await pb
.collection('connection')
.update(_existingConnection!.id, body: body);
} else {
// Create new connection
final result = await pb.collection('connection').create(body: body);
_existingConnection = Connection.fromRecord(result);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Connection saved successfully"),
),
);
}
widget.onConnectionComplete(
Connection(
title: _nameController.text,
id: _existingConnection!.id ?? '',
config: jsonEncode({
'addons': _addons.map((item) => item['url']).toList(),
}),
type: 'stremio_addons',
),
);
} catch (e) {
setState(() {
_errorMessage = "Error: ${e.toString()}";
});
} finally {
setState(() {
_isLoading = false;
});
}
}
final Map<String, String> _items = {
"Cinemeta": "https://v3-cinemeta.strem.io/manifest.json",
"Watchhub": "https://watchhub.strem.io/manifest.json",
"Subtitles": "https://opensubtitles-v3.strem.io/manifest.json",
};
void _removeAddon(int index) {
setState(() {
_addons.removeAt(index);
});
}
void _reorderAddon(int oldIndex, int newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = _addons.removeAt(oldIndex);
_addons.insert(newIndex, item);
});
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize:
MainAxisSize.min, // Add this to shrink-wrap the Column
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Connection Name',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a connection name';
}
return null;
},
),
const SizedBox(height: 20),
TextFormField(
controller: _urlController,
decoration: InputDecoration(
labelText: 'Addon URL',
hintText: 'https://example.com/manifest.json',
suffixIcon: IconButton(
icon: const Icon(Icons.add),
onPressed: () => _validateAddonUrl(_urlController.text),
),
),
validator: (value) {
if (_addons.isEmpty) {
return 'Please add at least one addon';
}
if (value != null && value.isNotEmpty) {
try {
final uri = Uri.parse(value);
if (!uri.isScheme('http') && !uri.isScheme('https')) {
return 'Please enter a valid HTTP/HTTPS URL';
}
} catch (e) {
return 'Please enter a valid URL';
}
}
return null;
},
),
const SizedBox(height: 12),
SizedBox(
height: 36,
child: ListView.builder(
itemCount: _items.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.only(right: 4),
child: ActionChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
label: Text(_items.keys.toList()[index]),
avatar: const Icon(Icons.add),
onPressed: () {
_validateAddonUrl(_items.values.toList()[index]);
},
),
);
},
),
),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(top: 12),
child: CircularProgressIndicator(),
),
),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
const SizedBox(height: 20),
if (_addons.isNotEmpty) ...[
const Text(
'Added Addons:',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Flexible(
fit: FlexFit.loose,
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _addons.length,
onReorder: _reorderAddon,
itemBuilder: (context, index) {
final addon = _addons[index];
final name = utf8.decode(
(addon['name'] ?? 'Unknown Addon').runes.toList(),
);
return Card(
key: Key('$index'),
margin: EdgeInsets.only(
bottom: index + 1 != _addons.length ? 10 : 0,
),
child: ListTile(
leading: addon['icon'] != null
? Image.network(
addon['icon'],
width: 40,
height: 40,
errorBuilder: (_, __, ___) =>
const Icon(Icons.extension),
)
: const Icon(Icons.extension, size: 40),
title: Text(
name,
maxLines: 1,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addon['url'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 4,
),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (int i = 0;
i < addon['types'].length;
i++)
Padding(
padding:
const EdgeInsets.only(right: 4),
child: RawChip(
padding: EdgeInsets.zero,
label: Text(
(addon['types'][i] as String)
.capitalize(),
),
visualDensity: VisualDensity.compact,
),
),
],
),
)
],
),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: Colors.red),
onPressed: () => _removeAddon(index),
),
),
);
},
),
),
],
Padding(
padding: const EdgeInsets.only(
bottom: 12.0,
top: 12.0,
),
child: ElevatedButton(
onPressed: _addons.isEmpty ? null : _saveConnection,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white70,
foregroundColor: Colors.black,
),
child: Text(
'Next',
style: GoogleFonts.exo2().copyWith(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
],
),
],
),
);
}
@override
void dispose() {
_urlController.dispose();
_nameController.dispose();
super.dispose();
}
}

View file

@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:madari_client/features/connection/containers/auto_import.dart';
import '../../settings/types/connection.dart';
import 'create_connection.dart';
class GettingStartedScreen extends StatefulWidget {
final VoidCallback onCallback;
final bool hasBackground;
const GettingStartedScreen({
super.key,
required this.onCallback,
this.hasBackground = true,
});
@override
State<GettingStartedScreen> createState() => _GettingStartedScreenState();
}
class _GettingStartedScreenState extends State<GettingStartedScreen>
with TickerProviderStateMixin {
final PageController _pageController = PageController();
int _currentPage = 0;
late AnimationController _animationController;
Connection? _connection;
late final List<OnboardingStep> steps = [
OnboardingStep(
key: 'create_connection',
title: 'Setup Connection',
description: 'Configure your Stremio addons',
icon: Icons.link_rounded,
gradientColors: [Colors.purple.shade800, Colors.blue.shade900],
),
OnboardingStep(
key: 'create_library',
title: 'Create Library',
description: 'Organize your data into libraries for better management',
icon: Icons.library_books_rounded,
gradientColors: [Colors.blue.shade900, Colors.teal.shade800],
),
];
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < steps.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
void _previousPage() {
if (_currentPage > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > 800;
return Stack(
children: [
if (widget.hasBackground)
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: steps[_currentPage].gradientColors,
),
),
),
// Content
Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isDesktop ? 1200 : double.infinity,
maxHeight: 800,
),
child: Card(
margin: EdgeInsets.symmetric(
horizontal: isDesktop ? 48.0 : 0,
vertical: isDesktop ? 32.0 : 0,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
color: isDesktop ? null : Colors.transparent,
elevation: 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: Stack(
children: [
Stack(
children: [
PageView.builder(
physics: const NeverScrollableScrollPhysics(),
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentPage = index);
_animationController.reset();
_animationController.forward();
},
itemCount: steps.length,
itemBuilder: (context, index) {
return _buildPage(steps[index], index);
},
),
],
),
],
),
),
),
),
),
],
);
}
Widget _buildPage(
OnboardingStep step,
int index,
) {
return SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 22),
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (index != 0)
IconButton(
onPressed: () {
_previousPage();
},
icon: const Icon(
Icons.arrow_back,
),
),
const SizedBox(
width: 6,
),
Text(
step.title,
textAlign: TextAlign.start,
style: GoogleFonts.poppins(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Text(
step.description,
textAlign: TextAlign.start,
style: GoogleFonts.poppins(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
),
),
),
const SizedBox(height: 0),
if (step.key == 'create_library')
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height *
0.6, // Adjust this value as needed
),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AutoImport(
item: _connection!,
onImport: () {
widget.onCallback();
},
),
),
),
if (step.key == 'create_connection')
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: CreateConnectionStep(
onConnectionComplete: (Connection connection) {
_connection = connection;
_nextPage();
},
),
),
],
),
);
}
}
class OnboardingStep {
final String title;
final String description;
final IconData icon;
final List<Color> gradientColors;
final String key;
OnboardingStep({
required this.title,
required this.description,
required this.icon,
required this.gradientColors,
required this.key,
});
}

View file

@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:madari_client/features/settings/service/selected_profile.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../widgetter/plugin_layout.dart';
import '../../widgetter/state/widget_state_provider.dart';
class HomePage extends StatefulWidget {
final bool hasSearch;
final bool isExplore;
const HomePage({
super.key,
this.hasSearch = false,
this.isExplore = false,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _state = GlobalKey<LayoutManagerState>();
late StreamSubscription<String?> _selectedProfile;
Widget _buildLogo() {
return Image.asset(
'assets/icon/icon_mini.png',
height: 32,
fit: BoxFit.contain,
);
}
@override
void initState() {
_selectedProfile =
SelectedProfileService.instance.selectedProfileStream.listen((data) {
_state.currentState?.refresh();
});
super.initState();
}
@override
void dispose() {
super.dispose();
_selectedProfile.cancel();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Row(
children: [
_buildLogo(),
const SizedBox(
width: 12,
),
const Text('Madari'),
],
),
actions: [
if (UniversalPlatform.isDesktop)
IconButton(
onPressed: () {
_state.currentState?.refresh();
},
icon: const Icon(Icons.refresh),
),
IconButton(
onPressed: () {
context.push("/downloads");
},
icon: const Icon(Icons.download_rounded),
),
],
),
body: LayoutManager(
key: _state,
hasSearch: widget.hasSearch,
),
);
}
}
class SearchBox extends StatefulWidget {
final String? hintText;
final EdgeInsetsGeometry? padding;
final double? height;
const SearchBox({
super.key,
this.hintText,
this.padding,
this.height,
});
@override
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
Timer? _debounce;
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<StateProvider>();
_controller.text = provider.search;
});
}
void _onSearchChanged(String value, StateProvider provider) {
if (_debounce?.isActive ?? false) _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 800), () {
provider.setSearch(value);
});
}
void _clearSearch(StateProvider provider) {
_controller.clear();
provider.setSearch('');
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: widget.padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Consumer<StateProvider>(
builder: (context, provider, _) {
return SearchBar(
controller: _controller,
hintText: widget.hintText ?? 'Search...',
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (value) => _onSearchChanged(value, provider),
leading: Icon(
Icons.search,
color: colorScheme.onSurfaceVariant,
),
trailing: [
if (provider.search.trim() != "")
IconButton(
icon: Icon(
Icons.clear,
color: colorScheme.onSurfaceVariant,
),
onPressed: () => _clearSearch(provider),
),
],
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(
colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
),
constraints: BoxConstraints.tightFor(
height: widget.height ?? 46,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
);
},
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more