diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index d4e2d36..d6ffa63 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -20,6 +20,7 @@ type AlbumWithTracks struct { type Artist struct { ID string + CoverArtID string Name string Favorite bool AlbumCount int diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 2c071bc..b927040 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -311,6 +311,7 @@ func toArtistFromID3(ar *subsonic.ArtistID3) *mediaprovider.Artist { } return &mediaprovider.Artist{ ID: ar.ID, + CoverArtID: ar.CoverArt, Name: ar.Name, Favorite: !ar.Starred.IsZero(), AlbumCount: ar.AlbumCount, diff --git a/res/albumplaceholder.png b/res/albumplaceholder.png deleted file mode 100644 index 316609b..0000000 Binary files a/res/albumplaceholder.png and /dev/null differ diff --git a/res/bundled.go b/res/bundled.go index c7d7800..e7f4497 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -10,11 +10,6 @@ var ResAppicon256Png = &fyne.StaticResource{ StaticContent: []byte( "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\b\x06\x00\x00\x00\\r\xa8f\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\v\xfca\x05\x00\x00\x00 cHRM\x00\x00z&\x00\x00\x80\x84\x00\x00\xfa\x00\x00\x00\x80\xe8\x00\x00u0\x00\x00\xea`\x00\x00:\x98\x00\x00\x17p\x9c\xbaQ<\x00\x00\x00\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\x00\x00\atIME\a\xe7\x04\x13\x000\nO*\x00\xc6\x00\x000\xa8IDATx\xda\xed\x9dy\x9c\\Wu\xe7\xbf\xe7\xbeWU\xbd\xafj\xa9\xb5/-,\xe1E\x8b\x8dw\x03\x06\x12b\xb0cB\xc2L&l\x19&\x042\x9f\x84$\x93\xe4\x93\xcfd2\x81\xc96\xf9d\x92\xc9$\x93d\xc8\x02\f\xf8\xe3\x10\x02\t\t\xc6\x03\xc4`\xc0\x18\uf6cc-˲\xadŒ\xac\xa5\xd5\xfbZU\xef\xbd{\xe6\x8fW\xd5\xea\x96z\xa9\xea\xeeں\xefן\xf6\xa7\xd5U\xfd\xfa\xbeW\xf7\xfc\xee\xb9\xe7\x9e{\xaePE\xecܸ\x1fR\xa0\xa1&\x81\xb5\xc0v`W\xeek;\xb0\x19\xe8\x04Z\x81\x06 \x01$D\xa4\xd2Mw\xacBT\x15 \xc8}M\x00\xc3@?p\x028\x0e\xbc\b\x1c\x06\x8e\xa9\xd2k\x92\x04\xe1\xb8p\xfc\xec3\x95n\xfa\x14\x15\xb7\x9c\x9e\xcd\xfb\x00\fB;p9p#\xf0\x86\xdc\xf7\xeb\x81\x16\xc0wF\xee\xa8%\x14\x05%\x04F\x80\xd3\xc0\v\xc0\x93\xc0#\xc0!\xd4\x0e\"b\x8f\x9cx\xb6\xa2\xed\xac\x88U\xf5lދ\xaa\x8a\x18\xb3\x86\xd8\xd8\x7f\x18x\x13p\x19\xd0\xec\x8cݱ\x12\xc9y\f\xa3\xc4^\xc1\xf7\x80o\x02O\x06\x91\xf4{\x06=v\xb2\xfc\x9eAY-\xadg\xcb>\x88\xdd\xf6+\x81w\x01w\x00W\x00u\xce\xe8\x1d\xab\x89\x9c\x18L\x02\a\x81{\x80\xaf\xa8\xea!\x81\xe0\xc8\xc9\xf2y\x05%\xb7\xba\xad[\xf7㫂P\x87r\x03\xf0A\xe0\x9d\xc0:g\xf4\x0eǔ\x18\x9c\x01\xee\x05\xee\x02\x9e@\xc8Ȅ\xf0\xca\xf9\xd2z\x05%\xb5\xc0܈\x9f\x04n\x02>\x02\xbcC\xa0\rg\xf8\x0eǥ((:H,\x04\x7f\v\xf2(\x10\x1c9Q:\x11(\x89%\xf6lه*\"\xc2\xe5\xc0ǀ\x7f\v\xb4\xbb\x11\xdf\xe1(\x00\x05\x85~п\a\xfe\x92L\xe60\x89\x84\x1e9\xf5\x83e\xffS\xder_07귈\xf0a\xe0ρ\x1f\x12\x91zg\xfc\x0eG\x81\b\x88Ѐp\x1d\xf0#\xf8~\x06\x91\xc3\x1d\xad\xdd\xd9\xc1\xe1\xb3\xcb\xfd\xa7\x96\x87\xed[\xf6p\xb4\xf5Yv\x0e\xef\xdf\v|\x02\xb8CD\x12\xe5}r\x0e\xc7\xcaCU3\xc0\xbf\x00\xbf\x83o^ \x8cX\xae\xe5\xc3e\x11\x80ܨ\x9f\"v\xf5?.\xc8\xce\xcag\x188\x1c+\x87\\^\xc1\x8b\xc0\x7f\x03\xbe\f\x04GN\x1cX\xf2u\x974\x05\xe8\\\x7f\r\x1b;\xd6\x01\xac\x01>\x0e\xfc7\x11\xe9v\xc6\xefp,/\x82 \"k\x80\xb7\x03u\n\a:Z\xbb\xd3K\x9d\x12,\xdaT{6\xefGDQ\xd8\x01\xfcO\xe0N\x111\x95~P\x0e\xc7JGU#\xe0K\xc0\xaf\v\x9c\x8cTXl\x12Ѣ<\x80\xed[\xf7b\x10\x14\xf6\x02\x7f+\xf0vqQ>\x87\xa3,\xe4\x06\xda+\x81=\xc0\xe3\"\xf4u\xb4v\xb3\x18o\xa0h\x01رe\x1f&\xb6\xf5\xab\x81O\x89\xc8\rn]\xdf\xe1(/\xf1x+;\x80}\xc0c\xc0\xf9\x8e\xb6\xe2E\xa0(\x01عe\x1f\x88\x80\xb2\x1f\xf8\x14p\x8d\x1b\xf8\x1d\x8b%N\x80\x9b\x89\xebN\x85#\xf1r\xe1\x16`?\xf0\bB_g\xeb:\x06\x86\xcf\x15|\x8d\x82\x05\xa0g\xcb\xfe\xf8\xc3Q\xae\x02>%\x883~ǢP\x05\xab\xf17I\x03\r\x89\xf8ߡ\x95\xfc\x1a\xb8\xa3`\x04\x116\x03W\x01\x0f\v\xa6\xbf\xb5e-C#\x85\x89@A\x8fz\xdb\xc6=x\x9e\x01\xd8\x06|VD\xde\\\xe9\xdbv\xd4&\xaaq\xee\xfb\xae5\xcam\xd7)\xbbwCK\x9b\xd2w\xde\xf0\x83\xe7-\xff\xfa\xb4pj\xd8 F\xdcbR\x11\xc4ޔ~\x1d\xf8\x19\xe0\xccD\x10p\xe6\xcc\xc1\x05\x7f\xaf \x0f\xa0\xb3}=@\a\xf0\xa7\xc0\xedn\xe4_}Lw\xd7\x17\xfb\xf1kn\xd4\xff\xd1+-\x9f\xf8\x98\xf2\xd6w\xc0\xf6˔\xee\x8d\xc2\xce˔\x1b\xaeS\xae\uec7ct\xc4pvH\x9c'P\x04\x12;O\xaf\x03:T\xf9v\xd2\xf3\n\xca\x1a\\P\x00\xa6%\xf9|\x1c\xf8\xb0\x8b\xf6\xaf.\xacBd\xc1#v\xd7u\t\xae\xbaUط\x05~\xfb\x97\x95\r\xbb\x14\vX\x1b_\xc4Z\xc0\x13\xba7\t\x97o\x80\xc7\x0e\bC\x13N\x04\x8a\"~XW\x89\x90\x01}\xb8\xa3u\xbd]H\x04\xe6\x15\x80\x9e\xcd\xfb \xe1\x81\xd5\x0f\x01\x1f\x17\x91d\xa5\xef\xd1Q\x1e\x14P\v\xed\xf5\xcam\xfb\x95\xf7\xdd\x16\xf1oo\xb5ܰ۲&\t\xbdC0\x96\x95\xfcȳ\xf0\xf5\x14\x92F\xf9埴\\}\x93\x12\xd9|rK\xfcz.\xbe\x84\xa2\xac\xed\x86\xf1>x\xfc\xb0\x00N\x04\x8a!\xb7Dx5\xc8\x11\x11s\xb0\xbdu-\x83\xf3\x04\x05\xe7\x14\x80\x1d[\xf6\xc6\xcb}V\xaf\x05\xfe\\D\xba*}s\x8e\xf2\xa1\x16\xf6l\xb2\xfc\xceG\x95\x9f\xfaI\xe5\xaa}\xb0e'\xec\xbeBx\xe3\r\xb0o\x93r\xea\xb8\xf2ڰ)\xc8@\xad¶\x0e\xe5\xe7ޫ4\xb6\x01:\x8br\b\xa8\n\xc6S\x92\x16\xee{Đ\x8d\x9c\x00\x14\x8b\x88\xd4\x01W\x80~\x17\xe4|{[\x17\x83ý\xb3\xbew\xce\xcc=A\xb0\xd0\x0e|\\\x84m\x95\xbe)G\xf9\xb0\x16\xb6\xb6+\x1f\xff\xa8\xe5\xba7[\xbc:%\x8c\x84(\x12B\xabHJ\xb9\xe6\xcd\xcao|Բ\xbd\xc3\xc6\xee\xfb\x82(m\x8d\x86\xe6f\x89\xf3\xda\xe70jɉ\xc0\xda.eM\x83\xa5\xa0K;f\xe3\xf5\xc0o*4\x8a\xce\xed\xe8\xcf*\x00=\x9b\xf7\"\x9e\x87\xc0\xfb\x81۪\xa0v\xa8\xa3L\xa8\x82'\xca\xfb~(\xe4\xf2\xab\xe3\xf9?:\xcdUG@\x85\xc8\xc2\ueac5\xf7\xbeE1\xe8\xack\xfa3\xae\v\x88\xa1ஔHZ\x12~\xbc1\xdeQ<\xb9Pݻ\x05~B\x10\xb6o\xd9;\xeb\xfb\xcc\x1c\xbf\x8dF\xd1\xe5\xc0/\x8a\x88_\xe9\x9bq\x94\x0f\x05\xba\x1a\x95\x9b\xaf\x05\xbcx4\x9e\xcbUǃk\xafU:\x1b\xb4$v\xeal\x7fi\xe4\xa6\x02\xbf\xa2\xe8V3\x87\xf2^\"\x00=[\xf6\x81j\x02\xf8y`g\xa5o\xc2Q^T\xa1\xa9Ah\xe90\xa8\xea\x9c\xf3\xef8<\x04\xeb\xd6ZַD\vz\x00\xb9ߪ\xf4\xed\xadBd/\xf0\xb36\n\xa4g\xf3\xa5^\xc0%\x02 \x00\"7\x03\xffέ\xf8\xadF\xe2Zn\x98\xc2>{/\xa9$\x12n\xac\xaeVr&\xfc\uf357\xd8?\x9b\x9a\xcf\x10\x80\x1d[\xf6a\xa1\xce*\x1f\x8d,\x1da\xa8\x84\xa1\x12F\x9a\xaf\\\xeaX%\xe4V\xe5\x1c+\x83\x8d\xc0\x87\xadU\x7fG|\x10\xcf\x143\xe6\xf7u(\x93\x81\u07b2\xa1ͼsOO\x92\xae\x8e\x04\n\x9c8\x9b\xe5\a\xc7\x02\x86Ҋ\x11\xb7,\xe3p\xd4\x12\"\x82\xaa\xfe\xb81\xf2Y\xe0\xf1\xe9\xafM\t\xc0\xff\xf9\xc0~N\x1d\x9fHn\xbf\xb2\xf5\x83\xd7\xde\xd2Ѳ\xf3\x9aV\x12u\xf1\xf2\xc1\xe4X\xc8\xf3O\x8d\xf2\xa9\xbb\xfbx\xe8\xc5\x00\xeb\x923V8\xc5~\xb8\xae3\xd4\x00\xeb\x80\xf7\xda\xd0>ٳy\x9f=r\xf2\x000M\x00\xde\xfb\xb1\xed\xa0\xec\xf5[\xfcے\xadI\xac\xbd\xe0\x02ֵ'\xb9\xfe\xed\x9d\xf4lK\xf1[\xff\xfd\x14\xf7\xbf\x10\xe29\x05p8j\x86\x9c\x17\xf0.\xe3\x9bO\x12\x1fM\x06\xe4b\x00\xe9\x93\uf8ee\xbb\x81\xba\r\xf5\xef\xf6\x9b\x93]\x97$v(\x84\x11t^\xd6\xc4G>\xb4\x96\xaeF\xc1\xba\x98\x80\xc3Qkl\x03\xde)\"\xecش\x0f\xc8\a\x01E\xc0\xe8z\x15\xb9}\xae\xdf\x14\xc0Fp\xf9\xfe\x16\xaeە,0\xfb\xcb\xe1pT\v\xb9U\xbd\x1fS\xd5\xd6|\xf5N\x039W\xdfr\x13\xaa\xbb\x17\xbaH\xaa\xd1c\xe7\xb6\x14\xe2\x1c\x00\x87\xa3\x16\xd9O\\\xce\x0f\x00\xf3w\xb7n\xe0\xb6Mw{\x93\xe7\xc6\xdfY\xe8n?\xdf\xf7\\\xdc\xc7\xe1\xa8M\x9a\x81\xb7\x1b\xcfгy\x1f\xe6\v\xe7Zi\\\xb7\xad\xfb\xe4\xcb\xe37\x17\x1a\xd7s\xf1?\x87\xa36\xc9M\x03n\xb5\x91mC\xc0\xf4\x06>g\x13\rW\xf5\x0eڭΰ\x1d\x8eU\xc1.\xe02\x00\xd3\xe2+Y\xf1n\xcc\x06ZW\xb8\x00\xb8\x00\x80\xc3Qô\x03\u05ca\b\xe6\xf9\xd1D}\x84\\\xe3V\xf5\x1c\x8e\xd5An\x1a\xf0\x860\n\x8d\xa93lP\x91]\xce\xfdw8V\x15Wz\xc6\xeb4\"\xda\x03\xacu\x02\xe0p\xac*6\x01\x9b\fq頦J\xb7\xc6\xe1p\x94\x956`\xa7\x11\xd8\xcd<\xb5\x01\x1d\x0eNJ$\x05\\f\x80\xed\x95n\x89\xc3\xe1(/\xb9\xfd\xbc\xdb\f\xf1\\\xc0\xe1p\xac&\xe2\x98\xdf\x16C|\xe4\x97\xc3\xe1X}\xac5 \xad\x95n\x85\xc3\xe1\xa8\b\xad\x06h\xa8t+\x1c\x0eGEhr\xd1\x7f\x87c\xf5\xd2\xe0\x04\xc0\xe1X\xbd\xd4;\x01p8V)\"b\x9c\x008\x1c\xab\x18'\x00\x0e\xc7*\xc6\t\x80ñ\x8aq\x02\xe0p\xacb\x9c\x008\x1c\xab\x18'\x00\x0e\xc7*\xc6\t\x80ñ\x8aq\x02\xe0p\xacb\x9c\x008\x1c\xab\x18'\x00\x0e\xc7*\xc6_\xfa%\x1c\x8e\xeaA5>\xb5\xc2\xe6\xca\xdc\v`$\xfe\xc6ս\xbd\x14'\x00\x8e\x15\x82bU0\xc0\xa6\xe6\x88\xeev\xa5>\xa1\fO\x18\x8e\xf7\x1b\x862\x82\x11w\xac\xdd\xc58\x01p\xac\x00b\xe3oI)\xef\xbby\x82;\xdf8ƚu\x01ƃl\xda\xf0ʑ\x14\xffp\x7f\x13\xdfx!I\xa4\xe2D`\x1aN\x00\x1c5\x8fU\xa15\xa5\xfc\xe7\x9f\x18\xe1\xf6\xb7\x0fa\x92\x16\xb5\xf1k\xc9&\xb8\xa6+î]\x13\xb4\x7f\xbe\x93\xcf?ԀU\xe7\t\xe4qA@GM\xa3\n\xa2\xf0S\xb7\x8cq\xc7ۇ\x10\xdfb\xa3ܤ\x1f\x01\x15\xc2\b\x1a\xdaC>\xfa\x13C\xec\xdb\x1cL\xc5\a\x1cN\x00\x1c5\x8e\x02\xeb\x9b-w\xdc2\x1e\x8f\xfc\xb3\xb8\xf8\"\x82\xb5б.˻\xaeK\xe3K,\x1c\x0e'\x00\x8e\x1a\xc7*l\xeb\f\xe9^\x17`\xedܮ\xbd\xa8\xa0\xa2\\\xbes\x92֔u\xe7[\xe7p\x02\xb0\x82P\x8d\r\xc2ZŪN-\x89\xadh\x14\x92)\xc1K,\xf0\xbeܨ\xdfҜ\xa5!a+\xdd\xea\xaa\xc1\x05\x01W\x00\xb1\xe1+\t\x11:\x1a \x95\x12\x82,\fL(Y\v\xc8\n\x8f|\x17qs\xea\x12\x02f\xe0\x04\xa0\xa6\xd1xΫʾ\x8d\u008f\xbf\xd1g߾\x80\x86VKz\xcc\xe3\xa5\x17\xe1+\x0f\xc0#G\x95\xd0-\x7f\xe5X\xf1>QQ8\x01\xa8aT\x05\x83r\xe7~\xcb\xcf\xff\x8c\xb0aG\x06\x8c\x8d\x03\\b\xd9y\xa5p\xc3\xcd\xc2\xdf~N\xb8\xfbA\xdc\x1a\xb8\xe3\x12\\\f\xa0FQ@U\xb9a\xbd\xe5\x97>dذ3\xc2b\x89\"AU\xd0H\bUi\xdb`\xf9ȇ\xe0-\x97\x01\xaan\xfcs\xcc\xc0\t@\xad\xa2\xd0L\xc4{oȲv\xa7\xc5Z\x98:\xf35\xfe\x16!^\xfej\xeb\xb6\xfc\xf4\x8fd\xe9\xf0#\xb7\xfc嘁\x13\x80\x1aE\x81:\x116\xb6\x00&\x8e\x05̆ \xa8\u0086\xdd\x1eM-\xc6M\x81\x1d3\xb8D\x0047D\x88w\xe1g\x92{\x97\xeb;Յg\x84\xe4\x1a\x0fe\xfe@\xb8*4\xb4)\xed-n\n\xe0\x98\xc9%A@1Bf \xe4\xf4\x03\xc3\xf4\x1f\x1c\a\x85\xb5oh\xa6\xfb\xc6f\x92m\xbeS\x81j\u0080\xb4\x16\x16\xd5KyJ{*B\xd5+\xe8\xfd\x8e\xd5\xc1\f\x01\x10\x11\xc6N\xa4y\xe6\x8fN\xd1\xfb\xc4\x186\x8c\xad\xfdկ\x0f\xb2\xf6\rM\xec\xff\xb5M\xb4mMU\xba͎\xe9\xc4!\xff\x02\xdf[\xe9\xc6:\xaa\x8d\xa9)\x80 D\x81\xe5\xd0g\xceq\xf6\x91QT\x15\xe3\v\xc6\x174R\xce><ʋ\x9f;7%\n\x0e\x87\xa3\xf61ӿ\x1b9\x96\xe6\xec##\x88\x89\xbd\x81<\"\x82\x188\xff\xf48\x93\xe7\x03\xb7\x97\xd2\xe1X!\\\xf0\x00\f\x8c\x9f̒\x1d\x89\xe6\xf4(\xa3Ɉ(\xed\xf2\xa8\x1d\x8e\x95\u0094\x00(\x05L\x11\xc5\xe5Q;\x1c+\t\x97\a\xe0p\xacb\xa6V\x01\xca1\xb0OmOո&\xd3TҚ\xf3*\x1c\x8e\x8apa\x19\xb0\xc4\xc1}\xab\n\x16:꠹APU\x06Ǖ\x91\xac`\x8cۤ\xe2pT\x82\xb2\xec\x06\x8c\xac\xb2\xbeYxϛ\x1b\xb8\xf5\x96\x16\xd6lLa\xadr\xea\x95\t\xbeq\xff0_}2\xcbh\x00Ʃ\x80\xc3QVJ.\x00\x91Uv\xaf5\xfc\xe6ϯ康u \xf5\xdeT\xc5\xd6MW\xb4p\xf5\x9b;\xd8\xfd\xa5\xf3\xfc\xcf\xcf\r2\x9cQ'\x02\x0eG\x19)\xa9\x00X\x85\xce:\xe5W?\xdc\xc9\r\xefX\xc3\xc4PD\xdfCC\f\x1c\x9c\xc0\xab3\xac\xbd\xb6\x89\x8e\xd77\xf0\x9e\xf7w32\x10\xf0\xa7\xff8J\xe4J6;\x1ce\xa3\xa4\x02\xa0VyӕInz[\a\xc3'2<\xf3G\xaf\xd1\xfb\xe4(QV\x11\xe0\xe5\xcf{l\x7fw'\xaf\xff\xf7\xeb\xb8\xf3\xddk\xb8\xf7{\xe3\x1c:g\xf1\x9c\x02\xac8\x04(j+\x92\xba\x15\xe7rP\xd2e@\x01\xf6\\ل\x97\xf0x\xe1\xd3\xe78\xf3\xd0\b6T\x8c'\x88\x11\xb2#\x11\x87\xef\xea\xe5\xf8\xbd\x03tm\xac\xe3\xaa\x1d\t\xb7_}\x85R\xccǺ\xd0\xeeF\xc7\xf2Q2\x01P\x8dw\xa0m\xdc\xe83|,Ù\a\x87\x11oZ\x8a\xb1\xc4;\x0f\xd5\u0089o\f\x12LX\x1a\xdb\x12n\xc3\xca\nE\x10l\x14W+^\xf0\xbd\x02\x13\x93\x1e\x13YㄠĔ\xd4\x03\xf0\x8d\xd2\xd4\x04\xc3G\xd3\x04c\xb3\xa7\x10\x8b@f8\"\xca(\"./i\xc5\"0:\x121\xdeo\x11\x919==ոS\x9e>e\x18\x18\x177\r(1\xa5\xb58\x8d\vWF\x19\x8bZ\x9d\xb1\xc1h:2u|\xb3\x1b\xfe\xab\x83\x82\x12ËB\x80\x81Q\xe5\xc0\x13\x82X\x10\xb9\xf4Ђ8?L\x89\xb2\xf0\xe4\x930\x19V\xfa9\xac|ܐ븈8I\xcbZ-h\xf4\r\x02!\x13.\xfcN#ʀM\xf0\xa9\xfb|^>\x90\x88\x97{%/4\xf1\x97\x88b\x10\x9ez\xc8\xf0\xe5\xc7\xc0\xae\xf4\xf3\f\xaa\x00'\x00\x8e\x19\x88\xc0\u060422\x14\x7f?\xaf\xab.\xd0{\xd6pf\xd8+\xc0P\x05c\xe0\x85^\xf8\xc4'\x85'\x1eN1\x99M\xc4?\x17P\f\xa3\xe3u|\xfb\xeb)~\xff\xd3\xc2\xe9\xb1\xf8\xe7\x8e\xd2ra\x19\xb0*\x1evU4bU#\xc0ȸ\xf2\xc2S\x96\x1d\x97\x9b\x9c\xab~\xd1.М\xab\x8e\x15^y6bbB\x10Y\xb8Ԙ\x00\x88p\xe0D\xc8\x7f\xb9k\r\xdb_\xb9\x8e\xdd[\xfa騟\xe0\xccP\x17/\x1em\xe3\xd4#\xdf\xe6t\xdf0\xc6Y\x7fY\x98\x12\x007\xfbv@.\x02o\rw\xddo\xb8b\xbf\xb0cOHt\xf1y\xda\x02\xc6\b\xaf\x1e\x84\xbf\xfb\x8e\xcf$\x85G\xebE\xe2\xda\x13\xe7&\xbax\xec\x1b\xbfDhSx^\x960h\xa4\xce\f\xb1\x9d\xa712\x84\v\xff\x95\x87\x151\x05\xc8\x1f\x8a\x19徬\xba㟗\x82\x18\xe1\xf9^\xf8\xfd\xbf\x12\x9e\x7f*I\x18\xfa\x18\x01Ob\xb7?\x8c<\x0e>\xe9\xf3\x87\x9f\x14\x9e=g\xe2\x8aQE\xfd\x85\\\xba\xa7\xfa\xd8L\x1b\xc1\xc4Z4hd\x85tǚ\xa2Ə\x06\x8b\xeb\xe1\x1bQ6\x19e\x8dUB\x03\xa7\xd4Я2\xd5\xcf\x1cőw\xd5\x1f>b\xe9\xfd\x9b6\xae\xb8\xf5\x1a.\xdf\xd1OWk\x1f\xfd\xa3M\xbcp\xbc\x95\x17\xbe\xf5\"\x87\x8f\x0f#\x8b\xdc\xc9)z\xd1\x1ft\x82\xbd\xec贓\xa0\x04f]\x85\xabi\x01P\x15\x1aE\xb9]Bn\x8a\"\x1a\xad\xa2\x16Ίp\xafI\xf0\x04^\xbe\xf4\x80\xa3HbW]\xe9\x1d\xed\xe2\xc1\xaf\xfe\x12\xa16Q\xd7\xd8K6\xdd\f\x91\xa5\xc7\xfe:\xc6\f\xce<@\xc2Q\x15\xc4;\uf554\x11\xba\x92\x1e\x9e@_6b\xc4#\xfb\xe0$|\xf7p\x9a;d\xf6n)\x02\xc3\xe3\x96/>>ɤ1U\x91\xa6YE\xe2[\\\xbb5\xef~Y\x14A\x9197J9*\x8bj<Ь\xefh\xe7\x80~\x8cS}o\x02\xe0d\xf2&n\xe8\x14:&\xbe\xc2\xf9\x8c\xd6L\xe2\xd2\xc563\xedl@\b\xe7=c\x1a\xac@`\xdc\xe1 KAU\xf1\x8c\xd0Լ\x86\xfa\x8ekhl\xdbF2\xe1O\x89\x82\xa3\xdaPRƢ\xc9\x1d\x9c\x1b\xd9\x17\xffH \xcc6s:s#mu\xa9\x9a6\x87\x99\xa7\x03O\xfd\xbf\x16\x9c\xd3\xf2\xa0S\xa7\xef\xc6\x06\xba\x94\x91ZU\xf1Dh\xe8\xdc\xc5)\xf9\x05\x86'\xaf\"\xe5\xf7\xb3\xa9\xed\xefH\r}\x85L\x10:O\xa0\x1a\x11\xc8j\x03\x91\x9dy2v&\xdbEB\x12@\xa6\xd2-\\4U\x10ʫNT\x15\v$\x8d\xd0j\x94&/v\xd3mn\x9du\xb1$S\x8d\x9c\xe1g\xe9\x1b\xb9\x99 \xdb\xc2\xd8\xc4vNd\x7f\x1ai؆\x14W5\xcfQ.\x14f\x1f\x10\xe3\xe9ے/\x9f[Q\x88\xbf\xca\xdb\aj:\x13\xb0T\x83e~\xa4\xbe:\xa1\xbc\x89\x88\xce(\"0\x86\xe7}\x8fo\x85\x86\x81h\xb1[U\x15ϴ3:\xb9\xfb\x82\x93\xa5\x90\t\xbb\xd0\xe4z\x84\x97b\x8f\xc3y\x01\xab\x06\x9b3\xf8fO1\x02c\x11Se\xd3\xca\xe1\rִ\x00\x94\x82|\x96\xd4\x0f\xa5\x94\x1f\xcbf\xa8\vlN\x91#6\x9b\x90m\xa9\x04\x9f\xc1\xa7/bQ\"`\xf3\xe5\x8ff\xfcM\x0fK\xd2%ڬ2\xac*]I\x8fw\xado\xe4M\x1d)\x12F86\x1e\xf0\xe53c<5\x1c\xce\xc8\xd8+\x155-\x00\xe3V\xb0H\xae\x94\xd8\xf2<(EXo\xe0\x87\x83\x80T`\x89\x88\x97x\x14\x10\xab\\\x9e\x0e\xb8))\xdc#ޢ>\xa0\xd9\x17Y$\xcelr\xac\x1a\xac*\x1b\xea|>\xb1\xbb\x93[:\xeb1\xb9:\x89\xfb\xda\xe1\xa65\x8d\xfc\xee\xe1~\xee??Y\xf2H\\M\xc7\x00^\xcb\n\xc12\x8f\x98J\xbc\x97\xa0-\x8cf\x94\xa7\x16\xe2tcTy\x9d.>\xc3P\xb9\xd4\x03pT9\xcb\xfcq\xa9ƛr>\xb0\xa9\x897u6\xa0\b\xa1\x8dcN\xa1\x85uu\t>\xb2\xad\x8dΤWP\x15\xe5\xa5P\xd3\x02P\x92G#\xe0\x85Q\x9cK|\xd1\xe8\x9e\xff\x97\t,n\x9b\xa1c\xb1(О0\xdc\xd0Q?\x95\xaa\x9b\xf7$E\xe2\xbd,\xafkJrUs\x12\xbb\x94?T\x005-\x00\xa52\xbf8\xe8\xeb\x8c\xdbQ:\x92\x9e\xa1\xd1\xf7g\x9d\xbc\xaaB\xca\x18\xba\xea\xfc\x92DŽjZ\x00j\x93KKn\xe7j\xe2V\xbaa\xb3\xb7\xb6\x88M\xa2\xab\x8f\xa5\x95O\x9fk\xc1\xaf\x9cc\x8f\x13\x802#0\xbd\x12v\x8c\x02%w\xf6\x1c\xcbF~\x87\xb4\t.*mT{\xd4\xf4*@-\"&`M\xc7\v\xacoz\x88n;ȸ\xe7s$\xbb\x89\xc4\xd0\b\x99\xda\xeeK+\x1a%\xb7\xb7\xd6\x04 \x11Du\xa4\x1a\xce`Fk\xfb\xf4\x12'\x00e\xc4\"\xac\xa3\x9f_K\xfc\x1e\x97\x9f\x1f\xa4y\xc8ƥˍ\xe4k2a\x81\xeeL\xc0\xdb4\x8cs\x82\xaaƺ\x94(h \n\x1a+ݐ\x8aa\x817w\xa6x\xff\x9afvN\b\xa9\xac\xc1\v\x85\x8eI\xe1\x16|>\xb2\xa5\x8d\x16\xdf\xd4\xecnN'\x00eB\x056F\x11u\xaa\\\xbc{\\s\xa9\x86=6\xa4\xae\xaa\x82JB\"5\x8c\x9f\x1c\x99啕\x8f\xaa\x922p{w\x13-\t\x9f(_HO\x04+B\x84p}G\x03{[\x92\xb3\x17ѩ\x01\x9c\x00\x94\x99\xd9\xf2\x87\xf2[\x00:}K\xbdY\x9c\x00L\r@f\xda\x17,\xd9WW\xc9\"\xe6\xe2@\x97\x82\xda\xe5\xb8|\t\xb9x\xa9%Oq\xd2U\xef\x1965\xa4r\x9f\x9b\\r\x95zϰ\xab)Q\xcd\x0fb^\\\f\xa0\xeaX\xdc\xd8*\x064\xa3\x84'\x15\xcd(\x92\x10\xbc\r\x82iZ\xfcB\xbe\x02\x91F\x18\x9d\xb9DiDi\xf2,Yr³\x8c\xee\xc0ҏ\x88\xd7X\xfcl\x1c\xbc\x13\xd1x\xa9Ng\xbc\xa3\x88\xf6Ȃ\v\xf3\x9e\xa9\xd5\b\x80\x13\x80*c\x91\x96d 8h\x99\xf8R@\xf6\x80E\x83\xf8\xbc\x0e\xff2C\xe3\a|\x92W-\xbe\x83\x9aYR\x94\x12\xc9Q\x12\xdeD\xae\x94\xd9b\xaf\xacE\xfc\xb4\xf0ǧi\x83}\xbe\x19\xfbr#\x9a1Hk\x88w\xf50\xb2)\x8d\xf8\xba\xb8u\xfb\x1a\x1d\xdd\v\xc1\t@\xad#\x82\xedUF\xff\"K\xf0\x82\x9d\xfaD\x15\xc8>\x19a\xfb\x95\xd6\xdf\x10\xe8^\xe2ߙf\x04Q\xd0@Dj\xf1\u05ca\x1b>\xeb\xb5\x17ݸ\x9c\xf1\x87\xf7\xac#z\xa4\x1d\r/\\?z\xb2\x15\xff\x8e^\xcc̓K\xfdCUO!\xb9\x89\xd3_w1\x80ZG \xfd\xed\x90\xe0pl\xfc\"r\xe1\xcb\x17\xc2c\x96\x89\xaf\x86q\xa9\xe7Ŏ\xd6&«\x1b#Y?\x88I\x8d!&\\\x06W\x9d\xb8'z\x01R7\x02f)\x99\x90\xf1\x8dE\x0f\xb7\x13=\u070eZA\xf2i\xe6\xf3\xb55.u\xe7\x04`堠\x01\xf3ڶ\x16\xe2\x17^\xf2;\x8a\x87\xf0\xae0Ϳ;\xff\x00\xcd\xe9\bc\xc1z0\\\xef\xf1\xb9t\x96\xe3\x14\xdf\xe9U\x15#\u008fz\xfd\xbc7\xf8;6\fd\xf1\x03Ȧ\x9e\xe1XK\x8a\xbf\xb2\xe3|\x0f).\xb1F\x14\xc6=t\xc8\xcf\x1fm<\xfb\xfb\x02S\xf0\x83\x88Ϟ\x84\xf7ߘfKK\x84\x8e&f\xbd\xac\x02\xb7\xef\xcfp\xffxȓ\xaf\xf9x5\xb6>\xea\xa6\x00\xb5N\x89:\x9c\x05\xf6'\x85\x9f\xf4<\x9a\xc7-\x1a\t\x91\n\x1a\n\xad\xa3\x96\x0f\xfa>W'M\xd1[\x98,\xb07\xe9\xf1\x11\x15\xb6\x9e\xcf\xe2\xa5A#HL(\xaf?\x9b\xe6?z\x86\x8d\xbeY\\!\x8ce\xf4ŭ¶v\xcb\x0f\xbfe\x10I\x05s^[-tm\x1b\xe7mWO`\xa8\xa6$\xae9\xb8H\xac\x9d\x008fE\x80[\x92>m&.\xbb\x96O\x80A\xe2\x7fw\x18\xe1\xcd)\x83\x14\x91\xb9\x18\x8f\xfepk\xd2c\rq\"\xcd\xf4\xebF\b;}õIo\x89\x1bm\x97\x8e*\xbcn]Ț\xce\x10\xbbP\xb96\xa3\xec{\xdd$͉Z\xd8\xd19\xf3\xa9:\x01\xa8\"\xf2ݬZ\x06\x91\xc6iUjf\xb43\xf7\xef͞!Qd%S\x0fa\xfd\x1c\xd7U\x89礻R\xb9\x93\xa7\n\xbel1nP\xe1\xefM\xd6\x11/\x1d.\xd0\x0eUhh\x8cHx\xd5\xf3\xd9\x15\x8a\x13\x80*A\x88\xddN\xd5j-\rr)F\x8b\x0f\x02\xce7\xb2\xe7\xaf\xe5\x99\xe9\xffr,/n\nP\x19t\xe1\x836\xc7#!tՁ\x1de\xc4\t@\x19\xb0\x16vtY\u07b6)\x9cs\xbel\x15^\xd7n\xb9\xe5\xf5\xd6\xd5\x06r\x94\r'\x00%F5\xceo\xff\xf1\xb7Z\xf6\xf5(v\x0e\xebV\xa0\xa9\x1e~\xf2G\x95\x96\x86\xea:w\xbe\xdc\x14\xef\x03iQ\xbfXU\x1b.+\x8c\x13\x802\xd0\xd4\x00\xd7^\xb3p\x19q\xab\xb0s\x8b\xb2i\x9d\xe6\xce t,\x88@:\xad\x84Aa\xf6?:\xe23\x19\x94h\x9aU\x83\xb37'\x00e\xc0O@}\xe3\xc2ɳ\xaaP_\xa7t5\xd9U\xed\x01\x14\x83\b\xf4\xf7\xc1\xc0\x99\xe4\x82\x1b\x93d\xd2\xe7\xe0\xe1F\x862\xa6\x16m\xb5$8\x01(\x13\xc5mAu\x14\x8a\x01\x8e\x8f\xfb\xdc\xfb\xfd\x0el\x94\x98\xf3ى\x81\x81W\x9a\xb9\xef\xe1&\xc2e\xde\xc2\\\xcb8\x01\xa82<\x94F\xafV\xeb˔\x1f\x11%P\xe1\v/(/\r\xdb\xd9\x0fl\xd5\xf8 \xd7\uf78dx\xac\x17\x8cTW\xdd\xc5J\xe2\x04\xa0\xca0\x02\xa9EV\x05Z\x9d\xc4\xc6\xab6Z͎\xc5j\xbe\xf7y\x98\xb9\x1d\xd8=\xa4\x9aBC\b\xcf\x14\xa2\x00\xcaĄǹ1o\xf5\xba\xbf5vߊ\x96\xfchp\x98\xe1\x01\xc4k\xcf5\xb3\xfe\\@[+\xbd\xa3l)\xf7\xb6\x10\x02\x8c*\x9c~U\x91\x05b\x86\"\xd0w\x16\x06\x86\xdd\xfc\xb7VP\x85PK\x9f\x13:c\n\x10\x18\x13ר\xafrT\xc17\xb0\xbbi\xee\xc2\x11\n\xb4'\x85\xadMR;\xa2V$\x13\"|\xebiC\xfa\x84\x9d۰\rD\xa7\x95\xef|\xd9\xd2?Ys\x03᪦\x1c\xddv\x86\x00\x88\xea\xf2\xd4z+1V\x95}\xdb}\xfeͭM\x88\xce~Ȳ*tw'\xf8\x0f\xefn\xa39\xb1\xf2Rk%W\xf9\xe6\xbeA\x9f\x03Cf\xf6\xe5/\xe2\x0f\xf8Ԩ\xf0/\xcf{X]J\x15_G\xb9)\xcdG\xb5`=\x80\xea\xee!\x9a\xdbUw\xcb\xf5M\xacY\x9fB-s\x96\xa4\x8aB\xb8\xfe\xa6fvo\xf2\xca2\x9f*7\x06\x18\xc4pP\xfd\xb9\x13`\x80!\x15\xfa0\xce\xf8k\x8eE\xec\x8aX\xe8W.2\x83\x1a\\\x05P|\x0fzz\xea\x10\xb3P\xee'4u$X\xbf\xb1\x8e2L\xa7\xcaO\xbe\xc2m\x01u\xa0k\xe1\x93ṳ4C\xd6J\xa8\a`\x04\xaf59\xff{\x14\xbc\x06\x83ߖ\xc0kI\x16tY%\x9e\x06\xf9\v\xa1EX\xaa\x01\xea\x17p\x17|\x94\x94۵_\x06j\xa5\xa3U\xfe\xce\xf2\xd7\xdbә\xa05a\xe6\xbc~\xd2\bWw\xf9$L\xac\x15S\x02 \xc0P\xa4d\x98\xddf\x04\b}CX\\\xb1\xb6\x9a#\x12aT\xe6/J\x1d \xa4k\xd4y\xaa\x05RV1\xac\xec\x9c]\x05\xc2%_e\xda\xf5\x1466X>\xf8\u0590\xfa\xfaكު\x80\x81w\xbd)do\xb7Ū\xce\x14\x803\x16Nz\x82\x11f\x9cw\x9e\xaf\xfd~\xcc\bö\x94\xeeoe\x85e\xe5v\xb7\xda\u008fja-jih\xee\xbf\xe5\xc2Z\xb8rK\xc0\xce+z\xb1q\x1d\xe7K\x10b\x11X\xb3\xb9\x9f\x1b/\x9f\x00\x95i\x02 B\xda*\x0f`\x18H\xf9\xf8\"\xf1\xb2\xa0*\x9e\b\xbd\tã*DJEO?q\xac|\xe2\xb1g\xf1\xa7\x1a\xafF\x04ع! Y\x1fλ\xe4\xad\n\xe2)\xafۜ%e.J\x05\x16\xe0X\b_\xf4\x85k\xeb}6\x8a\x12)\x1c\xb1\xc2\xd3\x11\xf4\xd9\xfc\xf1ͥ\x12\x00',\x8e\xd5A\x1c\x9eZ\xde\xfe\x9eH\x14:kR\x12\xc9\xf8\xbd3\x05 w\xca\xc3\xc9\x00^\v\x85\x94\x89\xcd=c/Զs\xa3\xbfñtt\xda\xff\x97\x8d\"\x8a\x1d\xe6\xd3\xe4/9\x1bPr\xc5\x12T\x95\xf4T\x8e\xb9\xe42͊7\xfeb\x8f\xa3t8V\v\xd5\xd0\xdb\xe7<\x1ct9Fz\x05\xa2\xa2V˜w\xe1p\x94\x93\x92\xaee\xa5#\xc3\xe9^-\xcc3\xb1J\x90u\xa5\xb0\x1c\x8erR2\x01\x10\x897\xed\xf4\x9d\x18C\xc3\x05\xdc\x00\x81\xf4H\xc8\xd9S\x19\xe7\x048\x1ce\xc4_\xfa%\xe6A\x84'\x9e\x18\xe7\x8a0\x89\x98\xb9\xd7\x0fL\x04/<:Ƴ'B\xccB\xf9\xfd\x0e\x87c\xd9(\xa9\x00\x88\b\x0f\x9d\xf5h\xfa\xde(\xd7\x13\xe7\xd9_\xbcN!\x02#\xfd\x01_\xfa\xc2\x00\xbd\x93+:\xf9\xcb\xe1\xa8:J\x1a\x03\x10\x81\xc0*ύ[\xc2y\f{\xc4¡\x91\x88\xe5_\x19u8j\x97\xb2\x17\x04)\xd5\x1f\x18\x12a\xc07SˋS7\x98\xcb2<\x9d\xf0\x18Aj\xa2\x18\x89ñ\x1c\xe4\xcd`\xae\x01O\x85eM\x15\x9e\x8b\x92\v\x80\x880b\xe1\x011L&=\xbc\x19)\xc6З2\x9b>\xbc\x81\xae;:\x99|5\x8dW\xefQ\xbf\xa3>\x9e\x1a8\xe3w\xac6\x14\xd8u\x1a\x02\x0f}n3\x92I\x80\x05MD\xb0\xbd\x17\xf6\x1f\a\xefBM\xe2\x9a\x15\x80\x197,\x90Z\x9f\"\xb51\x97\x98c\x81\xb8\xbc\x80ñ\xca\x100\x16\xae<\x01\xeb\a\xd1\xdeV\x88\ft\x8eºaԏ@/l5\xae}\x01\xc8\x1b\xb9\x12\x1b\xfd\xc5?w8V#Fѵ#\xb0v8\xf7\x83x5L4\x9e\x0f\xe4ͣ\xf6\x05\xc0\xe1p̂\xe42\x8e\xe5\xe2\x1fϠvW\x01\x1c\x8eUAi]Y'\x00\xd3\xf0EI\x88\xe6\xe6H\xf3\xe0V\x15k\x8fj\xfå\xd9\fT\xb2\x86\xc7\xc2\xe2\x04`\x1a\xa1\n\xa1\n\x89v\x7f\xde\"\x1f~\x93\x87\xe7\xf2\v\x1c+\x00'\x00ӱ\xc0\xa4\xa5\xbe\xa7\x1eI̞b\xac6NA\xf6[}g\xff5G\x15E\x86g\x9b\x9f\xcf|\xb1,\xfd\xcb\t\xc0\x14Bʳ\xd4\xd5+\xcd{\x9bi\xdeӄ\x86:\xb3\x8a\xb1U\xbcFC\xd7;;\x91\xa4\xdbc\xe0X$*\xe0[H\x06\xb3g\xec\xa9\xc4\xcbu\xb9\x8c\xbdR\xe2\x04`\x1a*qa\xf2dg\x82\xad\xbf\xb8\x99\xd6k[\x10#\xd8P\xd1HI\xb4\xfbl\xfc\xc0z:nm+\xed\aSE\x03\xd5JA\x80D\xb8Pp\xa7\x8c\xd4\x05\xb0\xfd|.KoZ\x89i\x05\xac@\xd7(\xbaf4\xf7Z\xe9pˀS(\x19k\x98\xb4\x06\xac\xd2te#\x97\xfdA\x0fC\x8f\f3~h\x02\xbfݧ\xed\xc6V\x1a/k@<\x81\xb0Zz\x92cAr6\xe4U[f\xe8\xae\xd3\xe8h\x1d\x1c]\x87ds\xa6(\x8av\x0f\xc15G!\x15\x00\xa9\x926\xc1\t\xc04\x84iyE\x91\x92\xe8H\xd0\xf5\xa3k\xe8\xba}\xda\v\xd5։\x1c\x05S-\x92-\xf9Ƥ\x02\xb8\xe1e\xd8v\x1e=\xdd\x0e\xd6@\xd3$l\xebC\x9b\xd2%\x1f\xfd\xc1\t\xc0\x9cH\xaet\xf9Tva\x19{O\xb5tTG\xe9Q\xcf¦\x81\xf8+\x7f\x14\x85\x82X\x99\xca\xd9/%N\x00\xa6\x13\x81\x8cۊ\xce\xc1\xb3V\xe8ͺ\x8fe\xb5 \x17W\xfdԩ\x17ʂ\v\x02NG\x15\x82\xca\xfa\xf8\"\xc4\xc9H\x8e\x1a\xa36W\x85\x9c\x00\\B\xe9\xa4W\n\x18\xd8\x13\xa2t$\xc2J?\x04\aԤA\x17\x8b\x13\x802!\x1e\xf8;\r\x18f\xafah\xc1\xdf,h\x83\x90\x89\xdc:`U\x90\x8f֙9\x94 ?gO֮`;\x01(\x17\n\xa9\x9b\f\x89\xdd\x06\".\x94/\xd38\xc7\xc0t\b\xf5w\xfahJ\x98\xb0%<\n\xc6Q8\n$#\xe8\x1e\xba\xf0\xef\x19/\x82\xd6g/\xbc^\x83\xba\xed\x04\xa0L\xa8\x82\xd7mh\xf9\xb5$\xf5?\xe2c\x9arǣ%\x85\xc4e\x86\x96_N\x92\xdc\xe7\xe1\xab\xd2\xe6\xd5\ue232\xe2\x10\x85\u05ff\x86v\x8d\xc4\t:\x96\xa9d\x1d5\x1aW\xdfi\x9b\xa8\xd9\xe9\x82\v7\x97\x13U\xfc\xad\x86\xe6_NP\xff\xb2G\xf8\x9a\xe2\xad\x15\xbc\x8d\x06o\xad\\\xc81\xa8\xc1\x91dŢ@\xcb$\xbc\xf1E\xf4\x85Mp\xbe%\xae\xb0S\x9f\x85\x9es\xf1W\r\am\x9d\x00\x94\x1b\v\x92\x10\x12Wz$\xae\xe2B\xb1\xc6\x12\x960sz\xb2D\x14\xb4}\x1c\xb9\xe9%\xc8\xf8S\xb9\xfc:-X\xab5\xfa\x94\x9d\x00\x94\x9b\xb92\n\xa7\xf5\x9fb\a\x94\xe9UѦw\xc3\xfceF\x8d\x10\xca,oXe,\xe5\xd6E%Nӭ\v.\xba\xdeEy\xfc5\x86\x8b\x01\xac\x00N\x1bC(\x82\\\xb4\xba\x90\xef\xf0\x87\xf0H\xc7)'+\x16\vd\U00081e4b^\xd3\xdc\x1e\xa0\xc1bN\xe5\x04\x86ƅ \x98VZ\x1b\x99\xf1\x1f\xc8\xd4A\x9f\xc3#\x1e\x99\xa8\xf6\x9e\xb1\x13\x80\x1aG\x14\x9e2\x1e\xaf\x18\x83\a\xa0\xb9z\xaf\xaax\xaa\xbc*\x86\uf21f\xef\xbf+\x14!\xa3ʣAD\x98s\xc6\xf3\xa6\xae(F\xa0\xdf*\x8fe\vO\xf22\x06^\xed\xf5\xe8\uf34b\xc3̥\x1d\"\n\x91\xe1\xf0KIƃ\x82/_58\x01\xa8q\x8c\xc095\xfc\x89\xd4\xf1\xb8\xe7\x13\xe4<\x81P\x84\x17\xc5\xe3OM\x1d\xaf\x8aW\"\xdb/\xde睾\xe1jΫN\xed\x96)\xf0\x9a\x12_\xf3k\x93\x11\xdf\xcbD\x18\x14\x9f\xb8s\xfb9q\xf8B:ˡ\xd0\x16\xdc\xe1\x058=l\xf8\xd6\xf7\xdb\xd0\xd0GDg\x8a@\xae\xb4\xb61p\xf6d=\xdf|\xa6a\xfe\x1a\x1fU\x8a\x8b\x01T!\xc5n\x02\x13\xe0\xb0x\xfc\xae\xd4s\xb9D\xb4\xa92\x82pX\f\xfdb\n2\xba٘\xd0x\xc4TeF\x894\xcd\x05\x13NXKPd`\xc1\x02\x93Ӧ\xcc3b\x16\x1a\xdf\xfb)k\x894\x16\xb7B1\"\fY\xe5\x0fdz\x1c\xb2\x1e\xd7\xfa>\xf5\xc4#\xff\xbd\x99\x90\x87\xb3\x16U\x8d7y\x15\xf2LEɪ\xf0\xb9\xefձ\xa9\xbb\x83\xb7\xdc:\x80\x97\f\xb1y'\u0080'\xc2\xc0\xd9:\xfe\xf2\x8b\xed<\xd3\xeb\xc7˺\x8bxΕ\xc4\t@\x15Rl'\xca\xcdF\x19U\xe1\x11\xf1Q\xb90\xd2.\xd6\xf8\x15\xf8n\xc6r{Ji3\x82͉\x80jllê|'c\xb1\n^\xa1\x7f@\x84H\x95'\x82\x88\x1fIy\x98\\\x01\x96\xd8eW\x8c\b#Vy:\xe7\xaa\x17\xdbn#\xc2\xf9H\xf9\xf4X\xc8\xe7%\xc2\x13\b\x14Ҫ\x18(\xd8\xf8\xf3O\xd5\b\x9c\x9b\x80\xdf\xfb\x87\x06\x8e\x9eL\xf0\xb6\x9bGY\xb7\xd6\xe2\x19a2\x1dq\xf8p\x1d\x7f\x7f\x7f\x03\xdfy%\x81\x05\x8a\xba|\x95\xe0\x04\xa0\xca\bU\x18\x0e\x17\x93\t(\x88\xc0r\xe5\x10\x1a\xe0\a\xa1\xe5\xeet\xc0\x87\xea\x134\xe7{\xb7\xc0\x88U>7\x19\xf0l\x10a\x8a0S\xc9]\xf7;و\xbd\x99\x88;S\x1e\xfe\x94\b\b\x01\xca?e\x02\x0e\x04\x85\xbbꗴ[\xe2\xc0\\:\x1f\xf9#\x1e\xa9\x17\xfd\x1cD蝄?\xbf?\xc9\x17\x1fkgs\xbbG\xca7\x8cL\x04\x1c\x1b\x10\x86\xb3\xf1s\xafE\xe3\a'\x00e\xa3\xb0\xfe\xa1\x84*\f\x86\xa5\x9a\xb3\x17\xd1^\x11\xac*wO\x84\xbc\x1cXޘ\xf4\xd8\xea\x1bND\x96og\"\x9e\x0eb7\xbd؎/\"\x8cY\xe5\xcfƳ\x1c\r}nIz\xb4\x19\x18P\xe5\xbbو\xaf\xa7\xc3\xdcu\x17\xff\x04d\x99#\x9eF\xc0*\x9c\x1e3\x9c\x1a\xb5\x80E0\x18\x01157ퟁ\x13\x802\x90\x9e\x84\xf3g\x95];.\x9dOO\xa1 F\x18\x1d\x85\xa1\xa1\xc2窥Dr.\xfb\xf7\xb3\x11\x0fg#R\"d\xd1x~\xce\xe2\x8d\xd4\xe4D\xe0\xf3\x93\x01_N\x87\xd4\x1b\x98\xb0\x90Q\x8d\x17\xd8*\x7f\xeb\xb3<\x8b\x9c\aS\x8d\x8d[\x02n\x15\xa0Ĉ\xc0DF\xf8\xe6\x83B6ÜKJ*`Dy\xf29\xe1\xb5\xf3\xd5c\x04\"2\xe5BgTQ\x8d\xff\xbdT\x81\xca\a\xcc2\xaa\fFJ\xb6\x8a\x8d\x7f%c\xb4\xc8\xe4\bG\xf1\x18\x03_{\xc4\xe3\xfe\xef\v\x9e\xd1xI\t⥤\xdc\xe3\xf7=\xe5\xe5\xe3\xc2g\xffI\xc8\x04\xd5g\b\x923\xfa\xe5lW\xfe\x9a\xa6\x04\xd7v\x14\x86\x0f\x04@\xa2\xd2\rYɈ\xc0ؤ\xf0?>\xe3\x91\xceD\xbc\xed\x8d\xd0ҤSY\xa4\x99,<\xf5\xbc\xf0\xbf>\xebq\xf0\x98\x14\x1eUw8\x16In\xf5%p\x02P&\x8c\x81\xb3}\xc2o\x7f\xd2\xe3\x9eo+\xd7_e\xe9\xd9\x06\xe7\xce\xc3\xd3\a\x85G\x9e7\f\x8d\xc4KO5\x1dUr\xd4\x12\x81\x0fL\x00\r\x95n\xc9j\xc0\x18\xc8\x06\xc2#?\x10\x1e\xfd\x81\xe0\xfb\x10E\x10\xa9\xe0\x99\xf8u\x87\xa3\x8c\x8c\x1b`\xb8ҭXM\x88\x80\xe7\xc5\x11\xff\xc8\xc6\vȾW\xbb\xebȎ\x9af\xd8\x00\x03\x95n\xc5j$\x9f<\xe2\f\xdfQ\t4\x0eA\xf5\x1b\xe0D\xa5\x1b\xe3p8\xcaL\x9c\xd7\xf0\xaa\x01\x8ei-V2(\xf6n\x1d\x0e\xc7\x14qR\x13\xc7\rp\x18\xa5\xf6\xaaP\x16\xaaY\xaa\x10\xba\x03\xfd\x1c\x8e\xe9\xf8F\x03\x0f^\x8c\x05\x00F*ݠ\xc2\x114R\xd2\xe7'\x81\xb9\v5\xe4\t\xd3\x11\xe3\xe73\xce\tp8r\xa8B}RG\xea\x85\xc3\x068\x0e\x9c\xadt\xa3\x8a!\xb4\xf0ܳc،\x8d\x83h\xb3\xa5\xd6j\\\xac\xe1\xf4\xb14\x87\x8eg\x8b\xda[\xeep\xacdT\xa1\xb3Ş\xde\xe8\x9b\xe3\x06\xd5s\xc0\x8b\x95nT\xa1Hn\a\xd67\x1fOs\xf0\xb1!<\x8f\xf8X\xb6i\xae\x80\xaa\"F\xb0\x93\x11\xff\xfc/\xfd\xbc6`\xabbs\x8d\xc3Q\r(\xd0\xdd\x15\x1czǩ\xb6\xf3F\x8c\xc9\x02O\xd7Ҟ\x00#©a\xe5\x8f?\xd9ˡdž1\xaax\x9e\xc4[3\r\xf8\xbe\x10\x8e\x85|\xe5\xae\xd3|\xf1\xbeQ\xd4\xe5\x99;\x1cS(B\xb2#x\xfa\xfdO<\x1a\xf89\xc3\x7f\x94\x1a\xcb\b4Fx\xf4H\xc8/~\xe25\xfe\xcd;Ǹ\xe5\xa6&ڛ}\xacU\x8e\x1fKs\xef}\xc3\xfc\xeb\x13\x93\x8c\x87ŕ\x96r8j\x96\x02\xc7pA\xc7\xf0x,\x1cHL\xd5\x038\br\x14\xb8\xb2\xd2\xf7P(B\x9cMwb\xc0\xf2'w\x0f\xf2\xd9\x7f\x1e\xa2)%X\xab\f\x8c+c\xd98\xb5v\xa5\xed\xdfv8fC\x81\xde\x01\x9f(\x10H̯\x04\xaa\xbc\x02\x1c\x040\xaa\x90\xce\xdas\xa0\x0f\xd6\xd24 \x8f1q:]\xff\x84\xf2\xea\x80\xe5\xe4\xb02\x11\n\xc6[\xfa\x9eu\x87\xa3V\x10\x81'\x8e%9{6\x89\x98\xb9W\xc7r9?\xdfk\xb4\xa9\xf3\b\x98\xa3'\x0fP\x9f4\n\xdc\ad*}#\x8b\xbdy#\x821\xf9\xbd\xe5n\xd5ϱ\xba0\x02G\xfb=\uee7f\x05M\xfb\x88Q\xa6'\xf8i\xae\x00\x85\x814p߸\xc9\xd2\xf4\xa6\xa1x\n\x90{ۣī\x01{+}3\x0e\x87\xa38$W\xb7\xf0\xae\xef5\xd0\xded\xb9\xf3\xb6!\x1aZ\xc2)\x11\x10 3\xe1\xf1\xda\xc9\xc4\xf3\xc1\xb8y<\x99\x04\x18\xbc \x00\xbe1g#k\xff\x9f\xaa\xeeu\xae\xb3\xc3Q{\x18\x81\xe1\xac\xf0G_m\xe2\xfb/\xa6\xb8\xfd\xfaqvn\xce\xc6\xf90\xe7\x13|\xf3\x89\x06\x9ez%\xf1՝\x97\xd9\xf3O=\xec\x03\xa7c\x018z\xe2\x00=[\xf6\x01\xfc\v\xf0\xb3@W\xa5o\xc6\xe1p\x14\x8f\x11%\x1d\t\xf7\x1fJ\xf2\xfd\xc3\tZR\xb9\x8aTY\x98\b\xe4L\xa4|\xe5\x95G\r\xaf\x9e>\x10\xbf\x7f\xfa/+<\v\xfckM\x1es\xeap8\x98:\x1f\xc2@\xa0B_Z8?)\xa4#\xc1\x18\xfd\x9ao\xecAo\x9a\xd5O}{\xe4\xc4\x01\x04\xb2\xc0]\xaa\xb5\xb47\xc0\xe1p\xccF\x1c\x1c\xcf\xe7\xc1\xe8\x10p\xb7\x88\x84GO\x1c\x98z\xcflE\xa8\x1e\x04\xbeQ\x83+\x82\x0e\x87c6bc\xfe*\xca#\x17\xbf4C\x00\x8e\xc4ʐ\x06\xfe\x1at\xb0\xd2\xedv8\x1cKG\xe1<\xf07\b\x99#\xd3F\x7f\x98\xfb`\x90\a\x81/\xaeN/`U\u07b4c\xa5\x12w\xe7ϫ\xe5\xd1ٺ\xf6%\x02\x90S\x88\x00\xf8\v\xd0c\x95n\x7fY1\x82\xa6\x8c\xd3\x00NJA\xd1\xc3\xc0'\xc5\x10\x1e9y\xe0\x92\xd7g\xf5\x00\x04E<\xffy\xe0OU\xb5\xf6\xaa\x05-\x16\xa1\x88\xb3\xae\x1d\x8e\xeaFU\xb3\xc0\x9fx\"\x87\xe7\x1a\xd3f\x15\x80WN<\x8bF!\xc0]\xc0\xd7W͐\x18)2\x11\x15\x9eG\xec\xb4\xc2Q\xad\xc4\xf3\xf7\xaf\x00\x7f\x1f\xa9r\xf4\xa2\xb9\x7f\x9e9\x8f\xa2ȥ\x10\x0e\x01\xbf\xad\xca\xea\x99\n\x14\xa3u\xaeԠc\x11\x8c\x8f{\xd8h\xfeS`re\xbb\x99\x1cI\x12-\xa2\x9fi\\\xea\xefw\x81Q\x95\xb9;\xf5\x9c\xad8z\xe2Y\x14\xf0\xc4<\x05\xfc\xbe\xaaNT\xfa\xc1\x95\x1aU%\fs3\x9e\x05\x84@\xd3!v$\xeb\xbc\x00GQ\x88\xc0\xe1\xd3>#\x03\x899O\x8a\x8eߧh(<}$\xc1dX\\'S\xd5Q\u0dcd\xf0\x9c\x05\x8e\xbe\xfa\xec\x9c\xef\x9dW\x86\x8e\x9e8@\xa4\x16\x94\xbb\x81\xbf\xd2Z\xdc/\\0B&\x84#G\xd3`\xe3\xe3\xbag#.7\x06\x83粼v.p\x95\x86\x1cEa\x04^>ox\xe8\xf1\x06\x8cJl\xe8ӭJc\xef\xdb\x188}\xbc\x8e\a\x9eK\xc55\xfc\v\xecg\xaa\x1a\x01\xff[\xe1\x1f\xad±9\\\xff<\xdeB\x17\x1c\x1c>KG[w\x04<\x03\\\x06\xec^\x89\x9b\x85D RȌ\x85\xdcz}#\xf5\xad\t\xd4ꌚ\x02\xaa\xf1\xbf=\xab\xfc\xbf\x7f\xea垇'\xc1\x95\x1bs\x14\x81H\x9c\xa2\xfb\xe2\xa9\x04;\xba\"\xb6n\xcc\xce\x1c\x86\r\xf8\x06\xfa\xce&\xf9\x1fww\xf2\xd8\xf1d\xc1'H\xe5\xc6\xe7\x7f\x00\xfe\xab\xc0\xf8\x91\x05\x8c\x1f\n\x10\x00\x80\xb6\xc6u\x88\x91\t\xe0\t\xe0jD\xb6\xae\xc4>/\bg\a-f,\xe0\xea}\x8d$\xea\xbd\x19\xea,F\xf0Dy\xea\xbb\x03\xfc\xf1g\xfa\x19\x9c\xcc\x15$q8\x8a@D\x19\x9a4<\xfdb\x1d~\xc6\xd0\xd9\x04I\x1fl\xe811\xe2\xf1\xd43\x8d\xfc\xd9\xdfw\xf0\xed\xc3ō\xfe\xc0}(\x1fC\xe8\x15k\x19\x189\xb7p[\n\xbd\xf2\xce\xcd{Qc@u\x0f\xf0\x7f\x11\xae\x96\x158\x01V\x85\x94\xa7\xdcyS=\x1fxO'[^\xdfD\xb2\xc1\xa0\x812ڗ\xe1\xa1\xef\x0e\xf2\x17_\x18\xe6h\x9fu\xc6\xefX\x02\x8aU\xc1C\xd9\xdcf\xd9\xd0\x11\xbb\xfd#\xe3p\xb4\xcfc4\x1b\x1f\x15_\x84\xeb\xff0\xf0\x1f@\x0e+\x96\xa3'\x9e-\xe8\xf7\x8a\xea\xc1=\x9b\xf7\xe5\x7f\xe3z\xe0\xd3\"rE\xa5\x1fc)P\x05\xb5Jw\x8b\xb0gG\x82\x8d\xeb\f\x93cʋ'\x02\x0e\x9f\x89\x98\x8c\x041+Q\xfe\x1c\xe5F5\xfe\xb29O3\xbf\x81\xa7\x98i\xa5\xaa>\x05\xfc\f\"ϊZ^)\xd0\xf8a\x111잍{\x90\xa4\x8fF\xf6\x06\xe0\xafDdEV\x10\xd2\\0\xc6Z\xe2\x15\x01\xb9Pz\xcc\xcd\xf9\x1d\xd5@\\\xe5K\x1f\x03~ND\x0e\xa8\x8d8r\xf2\aE]\xa3\xa0\x18\xc0t\x06G\xcf\xd1ּ\x06c\xfcS\xa0\x8f\x02W\x81lYiF\x11\a^ru\x06\xcd\xccz\x83\x0eG\xa5\xc9\x05\xfc\xee\a>\x8a\xc8\xf3\xa2\xb6h\xe3\x87E\b\x00\xc0\xe0H/m-k\x111g\x81\a\x80\xad\xc0e\xb2\x12\x97\a\x1c\x8e*#\xb7\xd4\xf7E\xe0\x17@\x8e(\x11GN\x14o\xfc\xb0H\x01\x00\x18\x1c9G\xb3\xb7\x0e/%\x83\xc4J\xe4\x03{D$Y\xe9\a\xe4p\xacTTu\x04\xf8cවsGN\x1c`px\xe1h\xff\\,Z\x00\x00\x86'\xcf18|\x96\xce\xd6\xee\tbO\xe0$\xb0\a\xa1݅\xc8\x1c\x8e\xe5C\xe3\f\xa1C\xc0\xaf\xaa\xf2\xd7\b\xe3G\vX\xe7_\x88%\t@\x9e\x81\xe1\xb3t\xb4tG\x99\xc9̳~\xc2\x7f\x00X\x0f\xd2#\xb2<\xd7w8V3\xaa\x9a\x01\xbe\x04\xfc\xbc\x97\xa8\x7f\xc0FY{\xf4d\xe1\x91\xfe\xf9X\xf6a:W]\xb8\x15\xf8i\xe0?!\xb2\xcd\xf9\x02\x0eG\xf1\xe4\x02}/\x01\x7f\x04\xfa\x05\x90\xb1B\xb2\xfb\x8aa\xd9G\xe8\xc1\xe1\xb3t\xb4vgT\xf5q\x11\xb9\x1fH\x00;\x10\xeaܴ\xc0\xe1(\fU\xed\x03>\x05\xfcJ\xa0\xf6[FLv9\\\xfe\x8b)\xa9E漁$p\v\xf0\x11\xe06AZ\x9d\x0e8\x1c\x97\x92;\xbe\xab\x1f\xb8\x17\xf8[E\x1f\x17\b\x8e\x14\x91\xd8S,%7\xc5\x1d\x1b\xf7\"\x9e\x80R\x87p\x13\xf0A\xe0\x1d\x02kݢ\xba\xc31\xe5\xea\x9f\x06\xbe\n܅\ua4c8dE\x86x\xe5\xd5\xe3%\xfd\xdbe\xb5\xc0i\x1e\xc1U\xc0\x8f\x01w\x00\xaf\x17\x91T9\xdb\xe1pT\x1cU\x14&\x81\xe7\x80{\x80{P=\x84\x10\x96rĿ\x98\x8a\f\xc1=[\xf6\xc5iv\xaak\x81\x1b\x81ہ7\x02\xdb\x10R.V\xe0X\x89\xe4F\xfaI\xe0\b\xf0]\xe0k\xc0\xe3\x92f@\x93\xe8\x91S\a\xcaަ\x8a[Zϖ\xbd(x\x82t\x13\x9fL|3\xb0\a\xd8\t\xac\x03\x9a\x11|'\n\x8eZ\"g\xec\x010\x02\x9c\x05^\x06\x0e\x00\x0f\x03\xcfY\xab\xbdb\xb0\x85\xee\xda+\x15UeU;\xb6\xecA5\x12\x11?%\xc8Z`\x1b\xb0\x1bؕ\xfb~\v\xd0\x01\xb4\x00\x8d\xc4+\f\xbe\xcb@vT\x82iF\x1e\x00\xe3\xc4\xc6\xde\a\x9c\x00\x8e\x03\x87\x88\x97\xf1\x8e\xa3ڗNG\xd9d\xd2\xd3c\xafU\xd6\xe8\xa7\xf3\xff\x01\xa1\xe2C>5\xe4̬\x00\x00\x00%tEXtdate:create\x002023-03-05T18:43:33+00:00\xcfnw\xe9\x00\x00\x00%tEXtdate:modify\x002023-03-05T18:43:33+00:00\xbe3\xcfU\x00\x00\x00\x00IEND\xaeB`\x82"), } -var ResAlbumplaceholderPng = &fyne.StaticResource{ - StaticName: "albumplaceholder.png", - StaticContent: []byte( - "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01,\x00\x00\x01,\b\x00\x00\x00\x00\\\x16ѩ\x00\x00\r\x04iCCPkCGColorSpaceGenericGrayGamma2_2\x00\x00X\x85\xa5W\a\\\x93\xd7\x16\xbf\xdf\xc8\x00\x92\xb0\xa7\x8c\xb0\x91e@\x81\x002\"3\x80\xec!\xb8\x88I \x81\x10b\x06\x02\xe2B\x8a\x15\xac[\x1c8**\x8aZ\\\x15\x81:Q\x8bV\xeaƭ\x0fj\xa9\xa0\xd4b-.\xac\xbe\x9b\x04\x10\xab\xed{\xef\xf7\xbe\xfc\xeew\xff\xdf=\xe7\xdcs\xce=\xe7\x9e{\x03\x80\xeeF\x8eD\"B\x01\x00yb\xb94\"\x91\x95>)=\x83N\xba\a\xc8\xc0\x18h\x03w\xa0\xcd\xe1\xca$\xac\xf8\xf8\x18\xc8\x02\xc4\xf9b>\xf8\xe4yq\x03 \xca\xfe\x9a\x9br\xaeO\xe9\xff\xf8\x10x|\x19\x17\xf6'a+\xe2ɸy\x00 \xe3\x01 \x9bq%R9\x00\x1a\x93\xe0\xb8\xed,\xb9D\x89K 6\xc8MN\f\x81x9\xe4\xa1\f\xca*\x1f\xab\b\xbe\x98/\x15r\xe9\x11RN\x11=\x82\x93\x97ǡ{\xba{\xd2\xe3\xa5\xf9YB\xd1g\xac\xfe\x7f\x9f<\x91bX7l\x14YnR4\xecݡ\xfde[\x193;\x88\x1fK\xf3\x13\x95<\xce\x00\xe04\x1e?4\fbh\a\xce\x14\xca\xd9Ƀ\xb8\\V\x90\x14\xa6\xb6\x13\xbf^,\b\x89U\xeb\"Pr8Q\xf1\x10;@\xec\xc0\x17E$\xaa\xe7!\xc4H\xe4\xf1\xca9\xe17\xa1@,\x8a\x8dQ\xfbE8Ǘ\xa9\xfc\x85\xdfD\xb2\\\x90\x1c\t\xb1'\xc4\xc9rir\xa2\xda\x1eby\x960\x9c\rq8Ļ\x05\xd2\xc8D\xb5\xbf\xc4>\x89H\x95gpMH\xee\x1ciX\x84zMH\x85REb\x8a\xdaG\xd2v\xbe8E9?\xcc\x11\xd2\x03\x90\x8ap\x00\x1f\xe4\x83\x19\xf0\xcd\x05b\xd0\t\xe8@\x06\x84\xa0@\x85\xb2\x01\a\xe4\xc1F\x87\x16\xb8\xc2\x16\x01\xb9İI!\x87\f\xe4\xaa8\xa4\xa0k\x98>$\xa1\x94q\x03\x12H\xcb\aY\x90W\x04%\x87\xc6\xe9\x80\agPK*gɇM\xf9\xa5\x9c\xb9[5\xc6\x1d\xd4\xe8\x0e[\x88\xe5\x0e\xa0\x80_\x02\xd0\v\xe9\x02\x88&\x82\x0e\xd5H!\xb40\x0f\xf6!pT\x01i\xd9\x10\x8fԢ\x96\x8fWY\xab\xb6\x81>h\x7fϠ\x96|\x95-\x9ca\xb9\x0f\xb6\x85@\xba\x18\x14\xc3\x11ِo\xb81\xce\xc0\xc7\xc2\xe6\x8f\xc7\xe0\x018C%%\x85\x1cE\xc0M5>^56\xa4\xf5\x83\xe7J\xdfz\x86\xb5΄\xb6\x8e\xf4~\xe4\x8a\r\xad\xe2)(%\x87\xdf\"\xe8\xa1xp}dК\xb7P&wP\xfa/~.7S8I$UK\x13\xd8\xd3j\xd5#\xa5\xd2\xe9B\xee\xa5e}\xad%GM\x00\xfdƒ\x93\xe7\x01}\xafNӹa\x1b\xe9\xedx\xe3\x94k\x94֒\x7f\x88꧶}\x1cո\x91y\xa3\xca$\xde'y\x03u\x11\xae\x12.\x13\x1e\x10\xae\x03:\xec\x7f&\xb4\x13\xba!\xbaK\xb8\a\x7f\xb7\x87\xed\xf9\x10\x03\xf5\xda\f\xe5\x84\xda..\x82\r\xdb\xc0\x82\x9aE*j\x1elB\x15\x8fl8\x1e\n\x88\xe5𝥒v\xfbK,\">\xf1h$=\x7fX{6l\xf9\x7f\xb5a0c\xf8*\xfd\x9cϮ\xcf\xff\xb2CF\xacd\x96x\xb9\x99D2\xad\xb6d\x80/Q\xaf\x872v\xfcE\xb1/bA\xa9+c?\xa3\x97\xb1\x9d\xb1\x97\xf1\x9c\xf1\xe0C\xfc\x187\x19\xbf2\xda\x19[!\xe5\t\xb6\n;\x8c\x1dÚ\xb0f\xac\r\xd0\xe1W3v\nkR\xa1\xbd\xd8Q\xf8;\xf07;\"\xfb3;B\x99a\xdc\xc1\x1d\xa0\xa4\xca\asp\xe4^\x19\xe93kD4\x94\xfcCk\x98\xf37\xf9=2\x87\x94k\xf9\xdfY4r\x9e\x8f+\b\xff\xc3.\xa5\xd9\xd2\xc0\x1f\x9e\xb3a\xf0\x8c\x8c\x03\xc90\xb2Ӡu\x02h\x8d\x14\xaem\tX\x00\xcaA%X\x0eր\r`\v\xd8\x0e\xea@=8\b\x8e\x80\xa3\xb0*\x7f\x0f.\x80ˠ\x1d܅'P\x17x\x02\xfa\xc0\v0\x80 \b\t\xa1\"\xfa\x88)b\x85\xd8#.\x88'\xc2D\x02\x910$\x06IDґL$\x1b\x11#\n\xa4\x04Y\x88T\"+\x91\r\xc8V\xa4\x0e9\x804!\xa7\x90\xf3\xc8\x15\xe46҉\xf4 \xbf#oP\f\xa5\xa0\x06\xa8\x05ꀎA\x99(\v\x8dF\x93ѩh6:\x13-F\xcbХ\xe8:\xb4\x06\u074b6\xa0\xa7\xd0\vh;ځ>A\xfb1\x80iaF\x985\xe6\x861\xb1\x10,\x0e\xcb\xc0\xb20)6\x17\xab\xc0\xaa\xb0\x1a\xac\x1eV\x81V\xec\x1aց\xf5b\xafq\"\xae\x8f\xd3q7\x18\x9bH<\x05\xe7\xe23\xf1\xb9\xf8\x12|\x03\xbe\vo\xc0\xcf\xe0\xd7\xf0N\xbc\x0f\x7fG\xa0\x12\xcc\t.\x04?\x02\x9b0\x89\x90M\x98E('T\x11j\t\x87\tga\xd5\xee\"\xbc \x12\x89F0/|`\xbe\xa4\x13s\x88\xb3\x89K\x88\x9b\x88\xfb\x88'\x89W\x88\x0f\x89\xfd$\x12ɔ\xe4B\n ő8$9\xa9\x9c\xb4\x9e\xb4\x97t\x82t\x95\xd4EzE\xd6\"[\x91=\xc9\xe1\xe4\f\xb2\x98\\J\xae\"\xef&\x1f'_%?\"\x0fh\xe8h\xd8k\xf8i\xc4i\xf04\x8a4\x96il\xd7hָ\xa4ѥ1\xa0\xa9\xab\xe9\xa8\x19\xa0\x99\xac\x99\xa3\xb9@s\x9df\xbd\xe6Y\xcd{\x9aϵ\xb4\xb4l\xb4|\xb5\x12\xb4\x84Z\xf3\xb5\xd6i\xed\xd7:\xa7թ\xf5\x9a\xa2Gq\xa6\x84P\xa6P\x14\x94\xa5\x94\x9d\x94\x93\x94۔\xe7T*Ձ\x1aL͠ʩK\xa9u\xd4\xd3\xd4\a\xd4W4}\x9a;\x8dM\xe3\xd1\xe6Ѫi\r\xb4\xab\xb4\xa7\xda\x1a\xda\xf6\xda,\xedi\xda\xc5\xdaUڇ\xb4/i\xf7\xeah\xe88\xe8\x84\xe8pt\xe6\xeaT\xeb4\xe9\xdc\xd4\xe9\xd7\xd5\xd7\xf5Ѝ\xd3\xcd\xd3]\xa2\xbb[\xf7\xbcn\xb7\x1eI\xcfA/L\x8f\xa7W\xa6\xb7M\xef\xb4\xdeC}L\xdfV?D\x9f\xab\xbfP\x7f\xbb\xfeY\xfd.\x03\xa2\x81\xa3\x01\xdb Ǡ\xd2\xe0\x1b\x83\x8b\x06}\x86z\x86\xe3\fS\r\v\r\xab\r\x8f\x19v\x18aF\x0eFl#\x91\xd12\xa3\x83F7\x8c\xde\x18[\x18\xb3\x8c\xf9Ƌ\x8d덯\x1a\xbf4\x19e\x12l\xc27\xa90\xd9g\xd2n\xf2Ɣn\x1af\x9ak\xba\xc2\xf4\x88\xe9}3\xdc\xcc\xd9,\xc1l\x96\xd9f\xb3\xb3f\xbd\xa3\fF\xf9\x8f⎪\x18up\xd4\x1ds\xd4\xdc\xd9<\xd1|\xb6\xf96\xf36\xf3~\vK\x8b\b\v\x89\xc5z\x8b\xd3\x16\xbd\x96F\x96\xc1\x969\x96\xab-\x8f[\xf6X\xe9[\x05Z\t\xadV[\x9d\xb0zL7\xa4\xb3\xe8\"\xfa:\xfa\x19z\x9f\xb5\xb9u\xa4\xb5\xc2z\xab\xf5E\xeb\x01\x1bG\x9b\x14\x9bR\x9b}6\xf7m5m\x99\xb6Y\xb6\xabm[l\xfb\xec\xac\xec&ڕ\xd8\xed\xb1\xbbc\xafaϴ\x17د\xb5o\xb5\x7f\xe9\xe0\xe8\x90\xe6\xb0\xc8\xe1\x88C\xb7\xa3\x89#۱\xd8q\x8f\xe3='\xaaS\x90\xd3L\xa7\x1a\xa7룉\xa3\x99\xa3sGo\x1a}\xd9\x19u\xf6r\x168W;_rA]\xbc]\x84.\x9b\\\xae\xb8\x12\\}]Ů5\xae7\xdd(n,\xb7\x02\xb7=n\x9d\xeeF\xee1\xee\xa5\xeeGܟ\x8e\xb1\x1b\x931fŘ\xd61\xef\x18^\f\x11<\xdf\xeez\xe8yDy\x94z4{\xfc\xee\xe9\xec\xc9\xf5\xac\xf6\xbc>\x96:6|켱\x8dc\x9f\x8ds\x19\xc7\x1f\xb7y\xdc-/}\xaf\x89^\x8b\xbcZ\xbc\xfe\xf4\xf6\xf1\x96z\xd7{\xf7\xf8\xd8\xf9d\xfal\xf4\xb9\xc94`\xc63\x970\xcf\xf9\x12|'\xf8\xce\xf3=\xea\xfb\xda\xcf\xdbO\xeew\xd0\xef7\x7f7\xff\\\xff\xdd\xfe\xdd\xe3\x1d\xc7\xf3\xc7o\x1f\xff0\xc0&\x80\x13\xb05\xa0#\x90\x1e\x98\x19\xf8u`G\x90u\x10'\xa8&\xe8\xa7`\xdb`^pm\xf0#\xd6hV\x0ek/\xeb\xe9\x04\xc6\x04\xe9\x84\xc3\x13^\x86\xf8\x85\xcc\t9\x19\x8a\x85F\x84V\x84^\f\xd3\vK\t\xdb\x10\xf6 \xdc&<;|Ox_\x84W\xc4숓\x91\x84\xc8\xe8\xc8\x15\x917\xd9\x16l.\xbb\x8e\xdd\x17\xe5\x135'\xeaL4%:)zC\xf4O1\xce1Ҙ\xe6\x89\xe8Ĩ\x89\xab&ދ\xb5\x8f\x15\xc7\x1e\x89\x03q\xec\xb8Uq\xf7\xe3\x1d\xe3g\xc6\x7f\x97@L\x88O\xa8N\xf8%\xd1#\xb1$\xb15I?iz\xd2\xee\xa4\x17\xc9\x13\x92\x97%\xdfMqJQ\xa4\xb4\xa4j\xa7NI\xadK}\x99\x16\x9a\xb62\xadcҘIs&]H7K\x17\xa67f\x902R3j3\xfa'\x87M^3\xb9k\x8aה\xf2)7\xa6:N-\x9cz~\x9a\xd94Ѵcӵ\xa7s\xa6\x1f\xca$d\xa6e\xee\xce|ˉ\xe3\xd4p\xfag\xb0gl\x9c\xd1\xc7\r\xe1\xae\xe5>\xe1\x05\xf3V\xf3z\xf8\x01\xfc\x95\xfcGY\x01Y+\xb3\xba\xb3\x03\xb2We\xf7\b\x82\x04U\x82^a\x88p\x83\xf0YNdΖ\x9c\x97\xb9q\xb9;sߋ\xd2D\xfb\xf2\xc8y\x99yMb=q\xae\xf8L\xbee~a\xfe\x15\x89\x8b\xa4\\\xd21\xd3o暙}\xd2hi\xad\f\x91M\x955\xca\r\xe0\x9f\xd26\x85\x93\xe2\vEgA`Au\xc1\xabY\xa9\xb3\x0e\x15\xea\x16\x8a\vۊ\x9c\x8b\x16\x17=*\x0e/\xde1\x1b\x9f͝\xddRb]\xb2\xa0\xa4s\x0ek\xceֹ\xc8\xdc\x19s[\xe6\xd9\xce+\x9b\xd75?b\xfe\xae\x05\x9a\vr\x17\xfcX\xca(]Y\xfa\xc7´\x85\xcde\x16e\xf3\xcb\x1e~\x11\xf1ŞrZ\xb9\xb4\xfc\xe6\"\xffE[\xbeĿ\x14~yq\xf1\xd8\xc5\xeb\x17\xbf\xab\xe0U\xfcPɨ\xac\xaa|\xbb\x84\xbb䇯<\xbeZ\xf7\xd5\xfb\xa5YK/.\xf3^\xb6y9q\xb9x\xf9\x8d\x15A+v\xad\xd4]Y\xbc\xf2᪉\xab\x1aV\xd3WW\xac\xfec\xcd\xf45\xe7\xab\xc6UmY\xab\xb9V\xb1\xb6c]̺\xc6\xf5v뗯\x7f\xbbA\xb0\xa1\xbdzB\xf5\xbe\x8d\xe6\x1b\x17o|\xb9\x89\xb7\xe9\xea\xe6\xe0\xcd\xf5[,\xb6Tny\xf3\xb5\xf0\xeb[[#\xb66\xd48\xd4Tm#n+\xd8\xf6\xcb\xf6\xd4\xed\xad;\x98;\xeaj\xcdj+k\xff\xdc)\xdeٱ+qי:\x9f\xba\xba\xdd滗\xedA\xf7(\xf6\xf4읲\xf7\xf27\xa1\xdf4ֻ\xd5o\xddg\xb4\xafr?د\xd8\xff\xf8@\xe6\x81\x1b\a\xa3\x0f\xb6\x1cb\x1e\xaa\xff\xd6\xfeۍ\x87\xf5\x0fW4 \rE\r}G\x04G:\x1a\xd3\x1b\xaf4E5\xb54\xfb7\x1f\xfe\xce\xfd\xbb\x9dG\xad\x8fV\x1f3<\xb6\xec\xb8\xe6\xf1\xb2\xe3\xefO\x14\x9f\xe8?)9\xd9{*\xfb\xd4Ö\xe9-wOO:}\xfdL\u0099\x8bg\xa3Ϟ\xfb>\xfc\xfbӭ\xac\xd6\x13\xe7\x02\xce\x1d=\xefw\xbe\xe9\a\xe6\x0fG.x_hh\xf3j;\xfc\xa3\u05cf\x87/z_l\xb8\xe4s\xa9\xf1\xb2\xef\xe5\xe6+\xe3\xaf\x1c\xbf\x1at\xf5Ե\xd0k\xdf_g_\xbf\xd0\x1e\xdb~\xe5Fʍ[7\xa7\xdc\xec\xb8Ż\xd5}[t\xfbٝ\x82;\x03w\xe7Ë}\xc5}\x9d\xfbU\x0f\xcc\x1f\xd4\xfck\xf4\xbf\xf6uxw\x1c\xeb\f\xedl\xfb)駻\x0f\xb9\x0f\x9f\xfc,\xfb\xf9mW\xd9/\xd4_\xaa\x1eY=\xaa\xeb\xf6\xec>\xda\x13\xdes\xf9\xf1\xe4\xc7]O$O\x06z\xcb\x7f\xd5\xfdu\xe3S\xa7\xa7\xdf\xfe\x16\xfc[[ߤ\xbe\xaeg\xd2g\xef\x7f_\xf2\xdc\xf4\xf9\xce?\xc6\xfd\xd1\xd2\x1f\xdf\xff\xe0Eދ\x81\x97\x15\xafL_\xedz\xcd|\xdd\xfa&\xedͣ\x81YoIo\xd7\xfd9\xfa\xcf\xe6w\xd1\xef\xee\xbd\xcf{\xff\xfe\xdf\t\x0f\xf8bN\xe4b\x85\x00\x00\x00DeXIfMM\x00*\x00\x00\x00\b\x00\x02\x01\x12\x00\x03\x00\x00\x00\x01\x00\x01\x00\x00\x87i\x00\x04\x00\x00\x00\x01\x00\x00\x00&\x00\x00\x00\x00\x00\x02\xa0\x02\x00\x04\x00\x00\x00\x01\x00\x00\x01,\xa0\x03\x00\x04\x00\x00\x00\x01\x00\x00\x01,\x00\x00\x00\x00\x12\x10\x17I\x00\x00\x01YiTXtXML:com.adobe.xmp\x00\x00\x00\x00\x00\n \n \n 1\n \n \n\n\x19^\xe1\a\x00\x00\x0fgIDATx\xda\xed\x9dk{\xa2:\x10\x80\xcf\xff\xffq\x83(V\xbcT-ZoT[Q\xacR`\xce\a\xedv%\x13\xc0\x96$\u061d\xf9\xb8ϳ\x15^2\x93\xb9e\xf2\x1f\xb2\x94\x96\xff\x18\x01\xc3bX\f\x8ba1,\x86\xc5\b\x18\x16\xc3bX\f\x8ba1,F\xc0\xb0\x18\x16\xc3bX\f\x8ba1\x02\x86Ű\x18\x16\xc3bX\f\x8b\x110,\x86Ű\x18\x16\xc3bX\x8c\x80a1,\x86Ű\x18\x16\xc3b\x04\f\x8ba1,\x86Ű\x18\x16#`X\f\x8ba1,\x86Ű\x18\xc1]\xc2J\xc2p\xe3\xfb\xfe\xc4\xf3\xbcn\xd7\xf3\xbc\x89\xef\xfb\x9b0L\x18֕\x1c\xb7+\xaf\xd7\x02\x89\xb4z\xdej{dX\x88\xc7հ\x03\xa5\xa43\\\x1e\xffaX\xa7\x97Q\vn\x92\xd6\xc8?\xfd\x83\xb0\xa2\xcd\u0601o\x893\xdeD\xff\x12\xac\x0f\xbf\x0f?\x92\xbe\xff\xf1o\xc0J\xd6\x03\xa8@\x06\xeb\xe4\xd7\xc3:Ll\xa8H\xec\xc9\xe1W\xc3ڸP\xa9\xb8\x9b\xdf\n+Y9P\xb98\xab\xe4\x17\u008ag6(\x11{\x16\xff2X\xc9B\x11*\x00\x00{\x91\xfc\"X\xe9J!*\x00\x00{\x95\xfe\x16X\xbb\aP.\x0f\xbb_\x01\xebc\fZd\x1c\xdd=,\xe5\x1a\xa8S\x17\x15\xc3\n;\xdf{\xf1\x85\xe7y#\xd7uo\v\xb4;\xe1\x1d\xc3J\xbcﮒ\xaf\xbf\xf1\xbe\xf3'\xfd\xd2̼\xe4^a\x1d\xda\xf0sX\x17/m;\x1b\x94\xd2\xe7\xf6\xe1>a\xf9\r\xa8\f\x16\"\"\xee\xe7%\x92\x15\x8d\xd5\x1d\xc2J\x86?1\xd6\xd28`\xf3X\xf8\t\x86ɽ\xc1\xfa\x81\n\xe6\xc1B\xc4d\xd37\xa5\x8a\x8a`\xad,P\x05\v\x11\xa3y\xbeŷVw\x04+\x19\xfd\xd4g*t\xdf\xd6\xf9>\xc9(\xb9\x17X\xd1ϳV%~%\xc8\xd5F7\xba\x0fX\xef\x15\xa4\xad\xcay\xbc\xa3\x1cew\xde\xef\x01֮\x8a\xf8\xa6\xecw\xc9I\xe7ۻ\xfa\xc3\xda6@\x1f,\xc47\xf9\xae\xdb\xd8\xd6\x1d֫\x05Zaa\xba\x94\xaed\xeb\xb5ް\xd6հ\x82\x9b\xf6\x93')\xadu\x9da\xad\xabJ\xb7t=\xcf\xf3|\x7f\x13\x96ʺ\x04R\xb7k]_X\x15\xe9\xe0u{C\xd7[\x06E\xc8\xe2\xa1\x16M\xac\x12\xd6V\x01\xab\xcb;\xf7fA\xbe\x9b\xb9\x96\xec+ֶ\x9e\xb0\x82\x06(\x95\xfc\x06\x87SW\xb2'\x06u\x84u\xa82\x7f\x1c\xee|ߟ{#\xd7\xce48\xc8k\x84\xe9\xb3\xc4\xdf:\xd4\x0f\xd6G\xa5\xe5\xe6\xbf\xcc\xd1\xce\x7f\xfa˗\xb2\x86\xfb[\xb7b\xe7\xa3n\xb0\x92j\xbb\x18\xb2\xde\xc1\xdf\xdd\\\xdd\xd7\x1b\x83\a7\xa9\x19\xac!\xa8\x84\x85\x88xZ\xfd\xf9\x1em_\xf2\xfaGڟ\x1f\xd6\v\xd6\x02\x94\xc3B\xc4\xe3\xf3\xa7Ce\xfb\x12\x1f\xa2G\x17\x8b\xea\x04+\xb0\xb4\xc0B\xc4`|\xf9\xa9\x0e\x1d'\xa7d*\xcd\n\xea\x03\xeb\xd4\x04]\xb0\x10\xa3\xe9\xc52\r\x8f\xe5i5Ou\x81\x95vA#,\xc4xv\xf6\xe8\xac\x19\xe5٧\xa4\xf9tӚ\xc0\x9a\x81^X\x88\xf1\xf4\xac\x8c.\xb5\xb8R2\xc95\xab\a\xac\x9d\xa5\x1d\x16\xe2\xa1'\xaf\x12\xa6T\xc2\xd9\xda\xd5\x01V\xac\xa0\xf9\xb1L\x8af}6]\x03\"ٞP{\xa2\x13\xd7\x00\xd6\x13\x98\x81\x85\xb1'M\x1f'T\xed\xe7\xc9<\xac\x00L\xc1B\xdcڲ\x14_De\xb8\x02Ӱ\x92\xb6AXx:\xeb\xdb3aӈ\x14H;1\fk\n&a}\xa6\x1a\x88\xf6\x86W\xe2\xafN\xcd\xc2:Xfa}\xaa\xa2\x1b\x97\xf9\x8c\xd6\xc1(\xac\x1e\x98\x86u\xa9銴R\xe2\xd9z&am\xd5\xe5E]\xd7\x1dN\xfd]\x890\xe5\xf4\x00\x00\xd0\x1541\"\x126[s\xb0\xd26(\x17g\xe4\x17\x1d`\x8d\xbb4-\xa2\xd6\xd4N\x8d\xc1Zj\xeaCnM\xf3;k\x93\x01\x00@O\xa0\xf5(\xfe\xa9\xa5)X\x89\xb6\xaem\x00g\x96\xd7\x16\x93>\x02\x00<\xa6Ŋh'\x86`\xcdձ\xc14\f\xc3\xf5l\xec~\xed\xb6\xd68gy%]2X&\x14qn\x06V\xacpa}\xfd\xca~\xde\xff\x03\xac'\xc7\x15w\x00\x006Ŋh\xc7F`\xcd@\a,DL߆\x9f\xbc\x86Rc\x1f\xb5\x01\xa0\x11\x16+\xe2\xcc\x04,\xa5\x16KX8\xfe\xa5ZaMeF\xe7h\x03@+k\xd9\xfc*\xad\xd6\xf7a\xad@#,D\f.N\xa6\xb3\xcbs\xfa\xfai\xa1w\xb3\xd4\x0f+u4\xc3B\xdc_\x92z\xb2\xc5\xf5L\x15rD\xbf\xd9I\xb5\xc3ڀvX\x88\xdb\xf3:\xe9\xd0~}\xda\x03\x00+k\xb6Ĵ\xe9F;\xac\xae\tX\x98̬\x9c~я\x16\x00t2\v'\x14\x9b\xbft\xc3:\x80\x11X\x88\xa1\x03 =\x16\x10P\x9e\x94\xe8>\x1c4Ú\x98\x82\xf5ٸF{\x00\x1e\x00XǢ\xef\xea酥:\xd2\xc9߇-Iz\x141i\x01\xc0 \U000cf0ea\xbc\x87o\xc2Z\x83AX\x97\xb692\xef\xf9J$b\x82\xaaZM\xbf\t\xab\xaf\x18\x96\xe7M\xfd\xadܴ\x84\xb6tm\r\x88D\x8cP\xeb\xe9\xeb\x84\x15Y\xa0C\x1a\x03\xd90\xb6cK\xe6\x03\x1c-\x00x)ps\xac\x0f\x8d\xb0t%\xb2\x00\xc0y\"݄cS\xd6\x1c\xe3\x01@+-\xb0\xb0\xbeFX=\xd0)ݭL\x13mb\xe1\xc5\r\x00X\x15\x14/z\xfa`i\xd2\xc2/\xe9\x10\n\x17X\x92\xe6\x98g1\xa49\nz\x18i\x83\xe5\x83vy\xd8Ӷ`&YZ\xeb\x82x\xc3\xd7\x06k\x04\x06d\"8GCIs\xcc\x04\x00܂\x14\xc9H\x17\xac\xd46\x01\v:Y\x03\x95\xb4\x01\xc0\x11\x1d\xcc#\x00@\x98o8\xecT\x13\xac\x1d\x98\x11;k\xe8w\x16\xedm\xf5\x01`\\\xe0\x18\xee4\xc1z6\x04K엙\x10\xa1 \"\xbe\x01@#\xce7\xb3Ϛ`\xf5\xc0\x98d\\\x82\xc4!B\xc1Kb\xf2\x1a\xec\xa9\n\xe7\xe1\x1b\xb0҆9XY\xa7}Ck\xd4\\\fi\xb2!O#\xd5\x02ko\x90\x95഻\xe4\xd2:\x89\xae\x94`;\xf6Z`-M\xc2\x02\xfb$n6b\xc4\xed\n\xaeԶ\x82\xba\xc57`\rU\xbb\b\xee\x93\xe7=\xb9\xb2\x91 \x19\xa7\xbdG:M\va\xc1\xc5Y\xe7a\xa8\x05\x96\xca\xd6\x19{\xb2\xfd\xe37%[z\xb4\xf0T\xd8\xf9\xc4\xe0\xe5\x00\x00\x8dDXlW\r5:`%\xea\x02Ö\x9f\xad\xfa\xf9D#\xedu\xfd&m\x93\r\f\x0ed\x1bn\xb3\xc1\xb4\x95h\x80\x15*\xcb^͉\xc7O\xe6\xe2\xde{]\xbfY\x01\xc0\x03\x99\xa8y\xceOj\x85\x1a`\xa9\x8a\xa2\xbb\x92&?\xe2\xf0\xf3Z0F!U\xd6\xec\xe5g\x1e|\r\xb0\x14\xd5u\x9e\xa4~O\xfa\x94_S\x1e\x00\xc0D(!\n\x8a&\xb8\x87\x13\r\xb0\x06JX\xe5v]\v\xb9\xbb\x97\xec\"r\xc8m(\xccM\xd3\f4\xc0R\xb2\x19>\xde\xe6\xadt\xae\xccZ\x83r\xb5\x86\x82\xa2\x8d\x7f\xbc\x1d\xde\x0eK\xc5f\xd8)ؙ\x84\xb38\xfb\xac\xab\xb5\xa2\\gO\x88\x81\xae\xb6C\xf5\xb0\"\x15\xfb`a\x03\xf7\xa9\x91SS^P~\xe9V\xb0\xf0\xc2v\x18)\x87\xa5\xc2s(\xd1\xe79\xcf\xc9\xdd\xed(\xa3u\x02\x80V~\x19?T\x0eK\xc19\x81V\t\xf70i\xc9sw\x89\x05\x00\x1fTn$\xcen\x90W\xf2\xa6\x1c\x96\x82\xc2})\x87\xc7\xcf\xc9݉\xee:\">\bf\xdf\xca\xdbS\x95\xc0\xaa>\xe7\xd0,\x95YJ\x9b\xf2\xdc\xdd#e\xe1\xfb\x02\xc1l\xaf\xe2B9\xac\xea{\x94K:\x87\x13y\xeenF9jO\xc2\xdaqo\xf1\xed*\x81\xe5U\x0e\xab\xe4\x01\xd3\xea\x8f\xccz\xcaaU^3,\x1b\xfdW\x9f\xed\x18\xdd\x1f\xacN\xd9_\xeeT\xfdˏ\xcaa=\x1a\xfb\xbe\x95\x7f\xa6\x81rX\x03C\xf6]A\xbaC=\xac\xca{\xfeJW;+\xaf\xed\xf6\x18V\x9d`\xb1\x1a\xb2\x81g\xd7\xc1\xb8\xeb\xc0N\xa9\xd1p\xa7d\xa7T\xf5]a\xeaÝl }ݏ\xf8\x02\xc4\xe8\xa5)\\\xf7~f:\a'\x86컆@:\x9b\xa2i\tѮp|a\x95\xb5\x0f\xfd\x9f\xa7h\xaa\x10\xf5)\x9aun\xda\xff\x9d\xaa\x9a\x04ٴofun\xbe\xf3\xbb\x15\x88\xfa䟐V\xbe\xca\xe7&@\xb4\x89\t\x05ό\xf9y(\xb1\xb4R\x05\xb7o\xaaO+\v\x05\x8b\xeb\xd4m\v\x00\x84Z\x8d\x931\xe3\xd9v\xe7\x12ye\x15M\x03\xea\v\x16B)\xecZ\x89z@\x8c\x12\x1ae\rDfK\xb5\vKa\x91\x8anr\xf5\xa5\xb0\xd4ʭcy@\xf4ԭ\xb3\x81\xd8\xfe\xa7E\xd6J\x1c\xbcT9,!\xed?\x16\xd4eH\xd5\xf0\xae[˲/?\xbc\xad|_\x898\x1a\xca\xf7\xd9H\xba+\x984\xb1\x89\xa0\x9dUW\xff\xa6pZM㎎Ɛ\xec\x93_o~i\x832\x06\x93쳉#4\xe4W8&\x8a\x9aXu\xb4\x1c\t\x8b\xe2(X\xf8\r\xb5\x85Zq\xbe\xdbԑ5\xb3u\u0530\xd2\xd2̶\xcf\xdf\x0egd\xd0\xd5\xce>\\*\" \xaf\xb0O\x16\xcaNU\xed5\xc0\x12\xa2\xff\xa9\x10\uf2a6s\x0e\xd9\xc6ϐH\"\xb4^\xb2\r\xb8/-U\xa8\xf44\xe0\n\xddlס4\xddZ\x16Y\x82\xffE\x8e\x83mN\xbe.#J\x82I\x13ԉ\x96\xd6na\x1f\xcf|\xa2\x01\x19\xa2\x8e\x84\x9cw*\xb3E\x1d\xd7\xf3<\xcf\xed\x80Z\xd1sh@h\r\xd9\n\x1b\x808\x1a\xe7 f\xaeNf\xcex~\x8a\x9e\xe3(\xfb\xfc\xfaLlQ\xe1!\x0e\x04\x85U~_V\xd5\xf6\xbd\x92#t\x1d\xb1X6\xa7\x13\x9d\x19\x9fbk\x90\x96\xa6#t\xe2\xe1̓\x10\t\xb6i\xcf?;d|k\x19\x83\xa5\xe9p\xa6X\xed\xf4\xc5\xfdP\xec#:Z\x84\xd7l\x8e\x96\xaec\xbfB\xe9 \x93H\x1eӕ\x93g\x00\xb0\xb2\x96\"0e\xe5u\x1d(\x17F\x15d\x8e\xb0\x85\x00`\x89&>q\bE\xc4c\xc7\b+m\xa3\n\xc4\xdaa\xa6\xa1ӥK';\x8bZr\xc9\xc4\x04\xacoݵV\xcdx\x95\x8c_\xb5\x01\x80Ƈ\xc4؉\xd3\xfa\xf6\x0f\xfaa\xe9\x1b\xafr\xb2\xf23\x0f\xa9C[\xd0\xd4\x05z\x8e\xd3F\xb7.j\x1c\xdc#:\x0fSq\xe95\x88\x89\xcfG\x1b$\xf7~\xbevk\xef8T6l\xacym\xb7Ӷ\xa4:\x1eX\x00\xd0$ǭ\xedƭ\x9akaec\xec\x88\xe1\x14\xd6QV+ud\xc3\xe9\x96\x03\xab\xc6ZXـ\xc4l\xe3PW\x96\xe4~\x06\x00h\xcaKv\xe1\x9b?\xf5\xbcZjau\xa37w\x84\xe3J\x96|\xa7\x00ŷ;+\x86\xa5w\xf4\xa68rp@d\xbd\xe8\xe3^S\x00\xe9\xbc_=\xb04\x0fu%ڴ\x0eD(Hw@\xcd\n\xca9\xcaai\x1e\x17L\x9c\xd1|$\xd2\xee\x92s9\xe7y\xbf\xed\xd0\x14\xacP3,t\x8b\x1e!\xed\x00@\x8b\x9e\x9ez\xbe\x1d\x9b\xbeSU=,\x17u\xc3\xda\x14Vx\x0f\x16\x88\xd7$\\\x17\x03\xdb[\x13\xb0\xf4\x0f\xcf'\xaee\xd8R\xae\xab\xe42\x92\xe4R\xdc\xe9\xef\xb5\xc32p-\x03q\xe1Gv\xc6\xff\xd9\x1b\x93-\x9e݅vo\xa7\x19\x96\x81\v?\xa8\x91\xf0\xd9 \"j\x01=\xd2\xf7\xb2\xb8.\xeez\xf7%\xd6\b\xcb\xc8U2\xc4\x01`;\xa22Xmilq\xfc,A6\x86\xdbT\x17,#\x97\x14Q\xd7_=\x92\x99\xaf\x8e\xfcƩ\xfd\x9f\xfcEc\xb0\bu\xc02t\xfd\x15u\xb1ښt^{9+?\x1c}\xc5\xce\rw<߄\xe1\xbbJX\x86.V\xa3\xac\x96\xa0\x88\x92\xcb\xf4\xae,\xdb\xcc\x01mb\xec\xca>jƃ\xa0\x88\xe7\xdb\xc2\a\xf9\xcf\x18Nu岌]\x06I^3\xba\xa6iu\x8bL\xc5\xd1\x1fiX`\x06\xaf\x19\xc57b\x9d\v5\xb0\xf3E\x8d\x9d\x12\xf9\xb6\xd3v\xe5\r\\W\xe1\xb8\xcaW4\a\x8b\x1a\xb3,\x0e\xff?\xd3r\x8e7\xfc\xddz%\xfd*\x82E]\xba=\x16\x9d\x8c.\x00@30\r\xcb\xf0\xa5\xdbd\xff\x9e\x98\u05fb4\x1c\xcfR\xb3\xb0\f_\xe7~\x9e\xf6\x9f\xfd\x80\x81$\xf5\x0e\xfd\xc8$\xacvb\x18\x169O\xa7Iti\xaf\xad[TQ\t\xac\x00M\xc3\x12\x864\x02\xd0gq\xce\t?鵗\xeaa\x8d\xd1<\xac\x98r\x8f\xa8\x00':\x1fdq\xb6f`9q\r`\x9dS\vBڔ\xb2\xe5\xcbs[\xe4\xf0h\x00\x96\xb5\xc3:\xc0\xa2\x87\xb5\x91\xb4.\xcdX\xd64\xd6\x0ek\x86\xf5\x80\x95\xbad\a\x14E+=ߩ\n\xf6<\xd6\v\xcbMk\x02\vO\xe4I\x88Q\x9a\x97\xf0\xb3\x9f#\x8d\xb0\x9a'\xac\v\xacssLIZ\x18\\z\u05ec\xf1N\x17,+\xc0\xfa\xc0\xc2\x05\x1d\x8aѺ\x96\xfa\x9f\x89\xb0\xd6\xec\xa8\x05\xd6\x02\xeb\x04Kr0\xb7-a\x91\xac\xfe\xf8\x1b]?R\x0ek\x88\xf5\x82\x95\xb8tbR\xaai\x9b\xaf\xff\xe0x\x9bH%,7\xa9\x19,\xfc\xa0Sw\x96\xbc\xbbg7\xfc\xcbҵ\xbd\xd5\xf6\xa8\x06V\xeb\x03\xeb\x06\v\x0f\x92\xf6\xff\xb9|ӎ_2\xa7\xd3;C\xcf\xf3}\x7f]\xe5hp\xfb\x80\xf5\x83%=\xe4\xd5\xcb۶?V\x8a\xafi\xabh#\xac\x1a\x96\xf4 N#\xbf\x13#\xd9\xcez\xcaZI\xad7\xac',|\x95\xbd\xf3\xa8(\xbcI\x83\x85\xe7*8\xe4km\xb0\xae\xb0\xe4s\x9b\x9cRal\x12n\xfc\xa5\xe7yc\xd7p\xf3\xa8\x16X\x97\x14\x1fٚxS\x86\xa4\xa2uU-\xab\xaaa\xc95\x11\xece\xaa\x19V\xb5:\xa8\x00\x16\xbeɏ\xf2\xb6\xb7Za5ް\xee\xb0p\x97s\xf0y\xf0\xae\x0fVc\x87\xf5\x87\x85\x87\x9c2\xbc5>h\x82\xe5\x1c\xf0\x1e`a\x94\xbb\x99\xf5\x03\x1d\xb0\xdc\b\xef\x03\x16&\xf9\x03\x98\xddM\xaa\x1a\xd60\xc1{\x81\xf5y,@\xae#\xf3\x93JX\x05g]\xea\x06\vâ\xfe\xa1\xeeJYZ\xd9\t\xf1\xbe`a\\ bundled.go -fyne bundle -append -prefix Res albumplaceholder.png >> bundled.go fyne bundle -append -prefix Res icons/freepik/disc.png >> bundled.go fyne bundle -append -prefix Res icons/freepik/disc-invert.png >> bundled.go fyne bundle -append -prefix Res icons/freepik/headphones.png >> bundled.go diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 4faf619..878e60b 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -164,7 +164,7 @@ type AlbumPageHeader struct { func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader { a := &AlbumPageHeader{page: page} a.ExtendBaseWidget(a) - a.cover = widgets.NewTappableImage(func() { go a.showPopUpCover() }) + a.cover = widgets.NewTappableImage(func(*fyne.PointEvent) { go a.showPopUpCover() }) a.cover.FillMode = canvas.ImageFillContain a.cover.SetMinSize(fyne.NewSize(225, 225)) diff --git a/ui/browsing/albumspage.go b/ui/browsing/albumspage.go index b44cab3..455594f 100644 --- a/ui/browsing/albumspage.go +++ b/ui/browsing/albumspage.go @@ -5,6 +5,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/widgets" @@ -75,7 +76,7 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller, } a.sortOrder.Selected = cfg.SortOrder iter := mp.IterateAlbums(a.sortOrder.Selected, a.filter) - a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im) + a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im, myTheme.AlbumIcon) contr.ConnectAlbumGridActions(a.grid) a.createSearchAndFilter() a.createContainer(false) @@ -190,7 +191,7 @@ func (a *AlbumsPage) Save() SavedPage { func (a *AlbumsPage) doSearch(query string) { iter := widgets.NewGridViewAlbumIterator(a.mp.SearchAlbums(query, a.filter)) if a.searchGrid == nil { - a.searchGrid = widgets.NewGridView(iter, a.im) + a.searchGrid = widgets.NewGridView(iter, a.im, myTheme.AlbumIcon) a.contr.ConnectAlbumGridActions(a.searchGrid) } else { a.searchGrid.Reset(iter) diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index d12446a..6fb2596 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -7,7 +7,6 @@ import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" @@ -131,15 +130,6 @@ func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track } } -func (a *ArtistPage) playAllTracks() { - if a.artistInfo != nil { // page loaded - for i, album := range a.artistInfo.Albums { - a.pm.LoadAlbum(album.ID, i > 0 /*append*/, false /*shuffle*/) - } - a.pm.PlayFromBeginning() - } -} - func (a *ArtistPage) playArtistRadio() { go a.pm.PlaySimilarSongs(a.artistID) } @@ -180,7 +170,7 @@ func (a *ArtistPage) showAlbumGrid() { Secondary: strconv.Itoa(al.Year), } }) - a.albumGrid = widgets.NewFixedGridView(model, a.im) + a.albumGrid = widgets.NewFixedGridView(model, a.im, myTheme.AlbumIcon) a.contr.ConnectAlbumGridActions(a.albumGrid) } a.container.Objects[0].(*fyne.Container).Objects[0] = a.albumGrid @@ -266,14 +256,16 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader { a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ SizeName: theme.SizeNameHeadingText, } - a.artistImage = widgets.NewImagePlaceholder(res.ResPeopleInvertPng, 225) - a.artistImage.OnTapped = func() { + a.artistImage = widgets.NewImagePlaceholder(myTheme.ArtistIcon, 225) + a.artistImage.OnTapped = func(*fyne.PointEvent) { if im := a.artistImage.Image(); im != nil { a.artistPage.contr.ShowPopUpImage(im) } } a.favoriteBtn = widgets.NewFavoriteButton(func() { go a.toggleFavorited() }) - a.playBtn = widget.NewButtonWithIcon("Play Discography", theme.MediaPlayIcon(), page.playAllTracks) + a.playBtn = widget.NewButtonWithIcon("Play Discography", theme.MediaPlayIcon(), func() { + go a.artistPage.contr.PlayArtistDiscography(a.artistID, false /*shuffle*/) + }) a.playRadioBtn = widget.NewButtonWithIcon(" Play Artist Radio", myTheme.ShuffleIcon, page.playArtistRadio) a.biographyDisp.Wrapping = fyne.TextWrapWord a.ExtendBaseWidget(a) diff --git a/ui/browsing/artistsgenrespage.go b/ui/browsing/artistsgenrespage.go deleted file mode 100644 index 1b5901f..0000000 --- a/ui/browsing/artistsgenrespage.go +++ /dev/null @@ -1,183 +0,0 @@ -package browsing - -import ( - "log" - "strings" - - "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/sharedutil" - "github.com/dweymouth/supersonic/ui/controller" - "github.com/dweymouth/supersonic/ui/layouts" - "github.com/dweymouth/supersonic/ui/widgets" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -var _ fyne.Widget = (*ArtistPage)(nil) - -type ArtistsGenresPage struct { - widget.BaseWidget - - isGenresPage bool - contr *controller.Controller - mp mediaprovider.MediaProvider - model []widgets.ArtistGenreListItemModel - list *widgets.ArtistGenreList - - titleDisp *widget.RichText - container *fyne.Container - searcher *widgets.SearchEntry -} - -func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider) *ArtistsGenresPage { - return newArtistsGenresPage(isGenresPage, contr, mp, "") -} - -func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider, searchText string) *ArtistsGenresPage { - title := "Artists" - if isGenresPage { - title = "Genres" - } - a := &ArtistsGenresPage{ - isGenresPage: isGenresPage, - contr: contr, - mp: mp, - titleDisp: widget.NewRichTextWithText(title), - } - a.ExtendBaseWidget(a) - a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText - a.list = widgets.NewArtistGenreList(nil) - a.list.ShowAlbumCount = true - a.list.ShowTrackCount = isGenresPage - a.list.OnNavTo = func(id string) { - if a.isGenresPage { - a.contr.NavigateTo(controller.GenreRoute(id)) - } else { - a.contr.NavigateTo(controller.ArtistRoute(id)) - } - } - a.searcher = widgets.NewSearchEntry() - a.searcher.OnSearched = a.onSearched - a.searcher.Entry.Text = searchText - a.buildContainer() - go a.load(searchText != "") - return a -} - -// should be called asynchronously -func (a *ArtistsGenresPage) load(searchOnLoad bool) { - if a.isGenresPage { - genres, err := a.mp.GetGenres() - if err != nil { - log.Printf("error loading genres: %v", err.Error()) - } - a.model = a.buildGenresListModel(genres) - } else { - artists, err := a.mp.GetArtists() - if err != nil { - log.Printf("error loading artists: %v", err.Error()) - } - a.model = a.buildArtistListModel(artists) - } - if searchOnLoad { - a.onSearched(a.searcher.Entry.Text) - } else { - a.list.Items = a.model - a.list.Refresh() - } -} - -func (a *ArtistsGenresPage) onSearched(query string) { - // since the artists and genres lists are returned in full non-paginated, we will do our own - // simple search based on the artist/genre name, rather than calling a server API - if query == "" { - a.list.Items = a.model - } else { - result := sharedutil.FilterSlice(a.model, func(x widgets.ArtistGenreListItemModel) bool { - return strings.Contains(strings.ToLower(x.Name), strings.ToLower(query)) - }) - a.list.Items = result - } - a.list.Refresh() -} - -var _ Searchable = (*ArtistsGenresPage)(nil) - -func (a *ArtistsGenresPage) SearchWidget() fyne.Focusable { - return a.searcher -} - -func (a *ArtistsGenresPage) Route() controller.Route { - if a.isGenresPage { - return controller.GenresRoute() - } - return controller.ArtistsRoute() -} - -func (a *ArtistsGenresPage) Reload() { - go a.load(false) -} - -func (a *ArtistsGenresPage) Save() SavedPage { - return &savedArtistsGenresPage{ - isGenresPage: a.isGenresPage, - contr: a.contr, - mp: a.mp, - searchText: a.searcher.Entry.Text, - } -} - -type savedArtistsGenresPage struct { - isGenresPage bool - contr *controller.Controller - mp mediaprovider.MediaProvider - searchText string -} - -func (s *savedArtistsGenresPage) Restore() Page { - return newArtistsGenresPage(s.isGenresPage, s.contr, s.mp, s.searchText) -} - -func (a *ArtistsGenresPage) buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel { - model := make([]widgets.ArtistGenreListItemModel, 0) - for _, artist := range artists { - model = append(model, widgets.ArtistGenreListItemModel{ - ID: artist.ID, - Name: artist.Name, - AlbumCount: artist.AlbumCount, - Favorite: artist.Favorite, - }) - } - return model -} - -func (a *ArtistsGenresPage) buildGenresListModel(genres []*mediaprovider.Genre) []widgets.ArtistGenreListItemModel { - model := make([]widgets.ArtistGenreListItemModel, 0) - for _, genre := range genres { - model = append(model, widgets.ArtistGenreListItemModel{ - ID: genre.Name, - Name: genre.Name, - AlbumCount: genre.AlbumCount, - TrackCount: genre.TrackCount, - Favorite: false, - }) - } - return model -} - -func (a *ArtistsGenresPage) buildContainer() { - searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) - a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, - container.NewBorder( - container.New(&layouts.MaxPadLayout{PadLeft: -5}, - container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), - nil, nil, nil, a.list)) -} - -func (a *ArtistsGenresPage) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(a.container) -} diff --git a/ui/browsing/artistspage.go b/ui/browsing/artistspage.go new file mode 100644 index 0000000..7e770fa --- /dev/null +++ b/ui/browsing/artistspage.go @@ -0,0 +1,187 @@ +package browsing + +import ( + "fmt" + "log" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/controller" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" + "github.com/dweymouth/supersonic/ui/widgets" +) + +type ArtistsPage struct { + widget.BaseWidget + + contr *controller.Controller + im *backend.ImageManager + pm *backend.PlaybackManager + mp mediaprovider.MediaProvider + artists []*mediaprovider.Artist + searchedArtists []*mediaprovider.Artist + grid *widgets.GridView + fullGridScrollPos float32 + searchGridScrollPos float32 + searcher *widgets.SearchEntry + searchText string + titleDisp *widget.RichText + + container *fyne.Container +} + +func NewArtistsPage( + contr *controller.Controller, + pm *backend.PlaybackManager, + mp mediaprovider.MediaProvider, + im *backend.ImageManager, +) *ArtistsPage { + return newArtistsPage(contr, pm, mp, im, "", 0, 0) +} + +func newArtistsPage( + contr *controller.Controller, + pm *backend.PlaybackManager, + mp mediaprovider.MediaProvider, + im *backend.ImageManager, + searchText string, + fullGridScrollPos float32, + searchGridScrollPos float32, +) *ArtistsPage { + a := &ArtistsPage{ + contr: contr, + pm: pm, + mp: mp, + im: im, + searchText: searchText, + fullGridScrollPos: fullGridScrollPos, + searchGridScrollPos: searchGridScrollPos, + } + a.ExtendBaseWidget(a) + + a.titleDisp = widget.NewRichTextWithText("Artists") + a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ + SizeName: theme.SizeNameHeadingText, + } + a.searcher = widgets.NewSearchEntry() + a.searcher.OnSearched = func(query string) { a.onSearched(query, false /*firstLoad*/) } + a.searcher.Entry.Text = searchText + a.grid = widgets.NewFixedGridView(nil, a.im, myTheme.ArtistIcon) + a.contr.ConnectArtistGridActions(a.grid) + + searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) + a.container = container.NewBorder( + container.NewHBox(util.NewHSpace(6), + a.titleDisp, layout.NewSpacer(), searchVbox, util.NewHSpace(15)), + nil, nil, nil, a.grid, + ) + + go a.load() + return a +} + +func (a *ArtistsPage) Reload() { + a.searchGridScrollPos = 0 + a.fullGridScrollPos = 0 + go a.load() +} + +func (a *ArtistsPage) load() { + artists, err := a.mp.GetArtists() + if err != nil { + log.Printf("error loading artists: %v", err.Error()) + } + a.artists = artists + a.onSearched(a.searcher.Entry.Text, true) +} + +func (a *ArtistsPage) onSearched(query string, firstLoad bool) { + // since the playlist list is returned in full non-paginated, we will do our own + // simple search based on the name, description, and owner, rather than calling a server API + var artists []*mediaprovider.Artist + scrollPos := float32(0) + if query == "" { + a.searchedArtists = nil + artists = a.artists + scrollPos = a.fullGridScrollPos + } else { + if firstLoad { // if reloading with a saved search state, set scroll position + scrollPos = a.searchGridScrollPos + } + if a.searchText == "" { + // if first search, capture scroll position of full, unsearched grid + a.fullGridScrollPos = a.grid.GetScrollOffset() + } + qLower := strings.ToLower(query) + a.searchedArtists = sharedutil.FilterSlice(a.artists, func(p *mediaprovider.Artist) bool { + return strings.Contains(strings.ToLower(p.Name), qLower) + }) + artists = a.searchedArtists + } + a.searchText = query + a.grid.ResetFixed(createArtistsGridViewModel(artists)) + a.grid.Refresh() + a.grid.ScrollToOffset(scrollPos) +} + +func createArtistsGridViewModel(artists []*mediaprovider.Artist) []widgets.GridViewItemModel { + return sharedutil.MapSlice(artists, func(ar *mediaprovider.Artist) widgets.GridViewItemModel { + albums := "albums" + if ar.AlbumCount == 1 { + albums = "album" + } + return widgets.GridViewItemModel{ + Name: ar.Name, + ID: ar.ID, + CoverArtID: ar.CoverArtID, + Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums), + } + }) +} + +func (a *ArtistsPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} + +func (a *ArtistsPage) Route() controller.Route { + return controller.ArtistsRoute() +} + +func (a *ArtistsPage) Save() SavedPage { + s := &savedArtistsPage{ + contr: a.contr, + im: a.im, + pm: a.pm, + mp: a.mp, + searchText: a.searchText, + fullGridScrollPos: a.fullGridScrollPos, + } + if a.searchText == "" { + s.fullGridScrollPos = a.grid.GetScrollOffset() + } else { + s.searchGridScrollPos = a.grid.GetScrollOffset() + } + return s +} + +type savedArtistsPage struct { + contr *controller.Controller + im *backend.ImageManager + pm *backend.PlaybackManager + mp mediaprovider.MediaProvider + searchText string + fullGridScrollPos float32 + searchGridScrollPos float32 +} + +func (s *savedArtistsPage) Restore() Page { + return newArtistsPage(s.contr, s.pm, s.mp, s.im, s.searchText, s.fullGridScrollPos, s.searchGridScrollPos) +} diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 7acab39..a4abaad 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -1,6 +1,7 @@ package browsing import ( + "fmt" "log" "github.com/dweymouth/supersonic/backend" @@ -33,15 +34,15 @@ type FavoritesPage struct { nowPlayingID string pendingViewSwitch bool - grid *widgets.GridView - searchGrid *widgets.GridView - artistListCtr *fyne.Container - tracklistCtr *fyne.Container - searcher *widgets.SearchEntry - filterBtn *widgets.AlbumFilterButton - titleDisp *widget.RichText - toggleBtns *widgets.ToggleButtonGroup - container *fyne.Container + grid *widgets.GridView + searchGrid *widgets.GridView + artistGrid *widgets.GridView + tracklistCtr *fyne.Container + searcher *widgets.SearchEntry + filterBtn *widgets.AlbumFilterButton + titleDisp *widget.RichText + toggleBtns *widgets.ToggleButtonGroup + container *fyne.Container } func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, mp mediaprovider.MediaProvider, pm *backend.PlaybackManager, im *backend.ImageManager) *FavoritesPage { @@ -56,7 +57,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro a.ExtendBaseWidget(a) a.createHeader(0) iter := mp.IterateAlbums("", a.filter) - a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im) + a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im, myTheme.AlbumIcon) a.contr.ConnectAlbumGridActions(a.grid) if cfg.InitialView == "Artists" { a.toggleBtns.SetActivatedButton(1) @@ -145,7 +146,7 @@ func (a *FavoritesPage) Reload() { iter := a.mp.IterateAlbums("", a.filter) a.grid.Reset(widgets.NewGridViewAlbumIterator(iter)) } - if a.tracklistCtr != nil || a.artistListCtr != nil { + if a.tracklistCtr != nil || a.artistGrid != nil { go func() { // re-fetch starred info from server starred, err := a.mp.GetFavorites() @@ -162,13 +163,12 @@ func (a *FavoritesPage) Reload() { tr.Refresh() } } - if a.artistListCtr != nil { + if a.artistGrid != nil { // refresh favorite artists view - al := a.artistListCtr.Objects[0].(*widgets.ArtistGenreList) - al.Items = buildArtistListModel(starred.Artists) + a.artistGrid.ResetFixed(buildArtistGridViewModel(starred.Artists)) if a.toggleBtns.ActivatedButtonIndex() == 1 { // favorite artists view is visible - al.Refresh() + a.artistGrid.Refresh() } } }() @@ -238,7 +238,7 @@ func (a *FavoritesPage) SelectAll() { func (a *FavoritesPage) doSearchAlbums(query string) { iter := a.mp.SearchAlbums(query, a.filter) if a.searchGrid == nil { - a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im) + a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im, myTheme.AlbumIcon) a.contr.ConnectAlbumGridActions(a.searchGrid) } else { a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter)) @@ -263,7 +263,7 @@ func (a *FavoritesPage) onShowFavoriteArtists() { a.cfg.InitialView = "Artists" // save setting a.searcher.Entry.Hide() // disable search on artists for now a.filterBtn.Hide() - if a.artistListCtr == nil { + if a.artistGrid == nil { if a.pendingViewSwitch { return } @@ -277,32 +277,31 @@ func (a *FavoritesPage) onShowFavoriteArtists() { log.Printf("error getting starred items: %s", err.Error()) return } - model := buildArtistListModel(fav.Artists) - artistList := widgets.NewArtistGenreList(model) - artistList.ShowAlbumCount = true - artistList.OnNavTo = func(artistID string) { - a.contr.NavigateTo(controller.ArtistRoute(artistID)) - } - a.artistListCtr = container.New( - &layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, - artistList) - a.container.Objects[0] = a.artistListCtr + model := buildArtistGridViewModel(fav.Artists) + a.artistGrid = widgets.NewFixedGridView(model, a.im, myTheme.ArtistIcon) + a.contr.ConnectArtistGridActions(a.artistGrid) + a.container.Objects[0] = a.artistGrid a.Refresh() a.pendingViewSwitch = false }() } else { - a.container.Objects[0] = a.artistListCtr + a.container.Objects[0] = a.artistGrid a.Refresh() } } -func buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel { - model := make([]widgets.ArtistGenreListItemModel, 0) +func buildArtistGridViewModel(artists []*mediaprovider.Artist) []widgets.GridViewItemModel { + model := make([]widgets.GridViewItemModel, 0) for _, ar := range artists { - model = append(model, widgets.ArtistGenreListItemModel{ + albums := "albums" + if ar.AlbumCount == 1 { + albums = "album" + } + model = append(model, widgets.GridViewItemModel{ ID: ar.ID, + CoverArtID: ar.CoverArtID, Name: ar.Name, - AlbumCount: ar.AlbumCount, + Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums), }) } return model diff --git a/ui/browsing/genrepage.go b/ui/browsing/genrepage.go index 6686373..26304d6 100644 --- a/ui/browsing/genrepage.go +++ b/ui/browsing/genrepage.go @@ -33,8 +33,6 @@ type GenrePage struct { titleDisp *widget.RichText playRandom *widget.Button - OnPlayAlbum func(string, int) - container *fyne.Container } @@ -55,7 +53,7 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba } g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs) iter := g.mp.IterateAlbums("", g.filter) - g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im) + g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im, myTheme.AlbumIcon) g.contr.ConnectAlbumGridActions(g.grid) g.createSearchAndFilter() g.createContainer(false) @@ -171,7 +169,7 @@ func (g *GenrePage) OnSearched(query string) { func (g *GenrePage) doSearch(query string) { iter := g.mp.SearchAlbums(query, g.filter) if g.searchGrid == nil { - g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im) + g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im, myTheme.AlbumIcon) g.contr.ConnectAlbumGridActions(g.searchGrid) } else { g.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter)) diff --git a/ui/browsing/genrespage.go b/ui/browsing/genrespage.go new file mode 100644 index 0000000..f8ce5f5 --- /dev/null +++ b/ui/browsing/genrespage.go @@ -0,0 +1,228 @@ +package browsing + +import ( + "log" + "strconv" + "strings" + + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/controller" + "github.com/dweymouth/supersonic/ui/layouts" + "github.com/dweymouth/supersonic/ui/widgets" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +var _ fyne.Widget = (*ArtistPage)(nil) + +type GenresPage struct { + widget.BaseWidget + + contr *controller.Controller + mp mediaprovider.MediaProvider + genres []*mediaprovider.Genre + list *GenreList + + titleDisp *widget.RichText + container *fyne.Container + searcher *widgets.SearchEntry +} + +func NewGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider) *GenresPage { + return newGenresPage(contr, mp, "") +} + +func newGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider, searchText string) *GenresPage { + a := &GenresPage{ + contr: contr, + mp: mp, + titleDisp: widget.NewRichTextWithText("Genres"), + } + a.ExtendBaseWidget(a) + a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText + a.list = NewGenreList(nil) + a.list.ShowAlbumCount = true + a.list.ShowTrackCount = true + a.list.OnNavTo = func(id string) { a.contr.NavigateTo(controller.GenreRoute(id)) } + a.searcher = widgets.NewSearchEntry() + a.searcher.OnSearched = a.onSearched + a.searcher.Entry.Text = searchText + a.buildContainer() + go a.load(searchText != "") + return a +} + +// should be called asynchronously +func (a *GenresPage) load(searchOnLoad bool) { + genres, err := a.mp.GetGenres() + if err != nil { + log.Printf("error loading genres: %v", err.Error()) + } + if searchOnLoad { + a.onSearched(a.searcher.Entry.Text) + } else { + a.list.Items = genres + a.list.Refresh() + } +} + +func (a *GenresPage) onSearched(query string) { + // since the artists and genres lists are returned in full non-paginated, we will do our own + // simple search based on the artist/genre name, rather than calling a server API + if query == "" { + a.list.Items = a.genres + } else { + result := sharedutil.FilterSlice(a.genres, func(x *mediaprovider.Genre) bool { + return strings.Contains(strings.ToLower(x.Name), strings.ToLower(query)) + }) + a.list.Items = result + } + a.list.Refresh() +} + +var _ Searchable = (*GenresPage)(nil) + +func (a *GenresPage) SearchWidget() fyne.Focusable { + return a.searcher +} + +func (a *GenresPage) Route() controller.Route { + return controller.GenresRoute() +} + +func (a *GenresPage) Reload() { + go a.load(false) +} + +func (a *GenresPage) Save() SavedPage { + return &savedArtistsGenresPage{ + contr: a.contr, + mp: a.mp, + searchText: a.searcher.Entry.Text, + } +} + +type savedArtistsGenresPage struct { + isGenresPage bool + contr *controller.Controller + mp mediaprovider.MediaProvider + searchText string +} + +func (s *savedArtistsGenresPage) Restore() Page { + return newGenresPage(s.contr, s.mp, s.searchText) +} + +func (a *GenresPage) buildContainer() { + searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) + a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, + container.NewBorder( + container.New(&layouts.MaxPadLayout{PadLeft: -5}, + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), + nil, nil, nil, a.list)) +} + +func (a *GenresPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} + +type GenreList struct { + widget.BaseWidget + + Items []*mediaprovider.Genre + ShowAlbumCount bool + ShowTrackCount bool + OnNavTo func(string) + + columnsLayout *layouts.ColumnsLayout + hdr *widgets.ListHeader + list *widget.List + container *fyne.Container +} + +type GenreListRow struct { + widget.BaseWidget + + Item *mediaprovider.Genre + OnTapped func() + + nameLabel *widget.Label + albumCountLabel *widget.Label + trackCountLabel *widget.Label + + container *fyne.Container +} + +func NewGenreListRow(layout *layouts.ColumnsLayout) *GenreListRow { + a := &GenreListRow{ + nameLabel: widget.NewLabel(""), + albumCountLabel: widget.NewLabel(""), + trackCountLabel: widget.NewLabel(""), + } + a.ExtendBaseWidget(a) + a.albumCountLabel.Alignment = fyne.TextAlignTrailing + a.trackCountLabel.Alignment = fyne.TextAlignTrailing + a.container = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel) + return a +} + +func NewGenreList(items []*mediaprovider.Genre) *GenreList { + a := &GenreList{ + Items: items, + columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}), + } + a.ExtendBaseWidget(a) + a.hdr = widgets.NewListHeader([]widgets.ListColumn{ + {"Name", false, false}, {"Album Count", true, false}, {"Track Count", true, false}}, a.columnsLayout) + a.list = widget.NewList( + func() int { return len(a.Items) }, + func() fyne.CanvasObject { + r := NewGenreListRow(a.columnsLayout) + r.OnTapped = func() { a.onRowDoubleTapped(r.Item) } + return r + }, + func(id widget.ListItemID, item fyne.CanvasObject) { + row := item.(*GenreListRow) + row.Item = a.Items[id] + row.albumCountLabel.Hidden = !a.ShowAlbumCount + row.trackCountLabel.Hidden = !a.ShowTrackCount + row.nameLabel.Text = row.Item.Name + row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount) + row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount) + row.Refresh() + }, + ) + a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list) + return a +} + +func (a *GenreList) Refresh() { + a.hdr.SetColumnVisible(1, a.ShowAlbumCount) + a.hdr.SetColumnVisible(2, a.ShowTrackCount) + a.BaseWidget.Refresh() +} + +func (a *GenreList) onRowDoubleTapped(item *mediaprovider.Genre) { + if a.OnNavTo != nil { + a.OnNavTo(item.Name) + } +} + +func (a *GenreListRow) Tapped(*fyne.PointEvent) { + if a.OnTapped != nil { + a.OnTapped() + } +} + +func (a *GenreList) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} + +func (a *GenreListRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index c273173..1f14404 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -6,7 +6,6 @@ import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" @@ -208,7 +207,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a := &PlaylistPageHeader{page: page} a.ExtendBaseWidget(a) - a.image = widgets.NewImagePlaceholder(res.ResPlaylistInvertPng, 225) + a.image = widgets.NewImagePlaceholder(myTheme.PlaylistIcon, 225) a.titleLabel = widget.NewRichTextWithText("") a.titleLabel.Wrapping = fyne.TextTruncate a.titleLabel.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index a31a185..394eac0 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -12,6 +12,7 @@ import ( "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -94,7 +95,7 @@ func (a *PlaylistsPage) createListView() { func (a *PlaylistsPage) createGridView(playlists []*mediaprovider.Playlist) { model := createPlaylistGridViewModel(playlists) - a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager) + a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager, myTheme.PlaylistIcon) a.gridView.OnPlay = func(id string, shuffle bool) { go a.contr.App.PlaybackManager.PlayPlaylist(id, 0, shuffle) } diff --git a/ui/browsing/router.go b/ui/browsing/router.go index c337347..e0f710b 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -34,13 +34,13 @@ func (r Router) CreatePage(rte controller.Route) Page { case controller.Artist: return NewArtistPage(rte.Arg, &r.App.Config.ArtistPage, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager, r.Controller) case controller.Artists: - return NewArtistsGenresPage(false, r.Controller, r.App.ServerManager.Server) + return NewArtistsPage(r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Favorites: return NewFavoritesPage(&r.App.Config.FavoritesPage, r.Controller, r.App.ServerManager.Server, r.App.PlaybackManager, r.App.ImageManager) case controller.Genre: return NewGenrePage(rte.Arg, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Genres: - return NewArtistsGenresPage(true, r.Controller, r.App.ServerManager.Server) + return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: return NewNowPlayingPage(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.PlaybackManager) case controller.Playlist: diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 78ff0c1..2f0fb03 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -140,6 +140,47 @@ func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) { } } +func (m *Controller) ConnectArtistGridActions(grid *widgets.GridView) { + grid.OnShowItemPage = func(id string) { m.NavigateTo(ArtistRoute(id)) } + grid.OnPlay = func(artistID string, shuffle bool) { go m.PlayArtistDiscography(artistID, shuffle) } + grid.OnAddToQueue = func(artistID string) { + go m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), true /*append*/, false /*shuffle*/) + } + grid.OnAddToPlaylist = func(artistID string) { + go m.DoAddTracksToPlaylistWorkflow( + sharedutil.TracksToIDs(m.GetArtistTracks(artistID))) + } +} + +func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track { + server := m.App.ServerManager.Server + if server == nil { + log.Println("error playing artist discography: logged out") + return nil + } + + artist, err := server.GetArtist(artistID) + if err != nil { + log.Printf("error getting artist discography: %v", err.Error()) + return nil + } + var allTracks []*mediaprovider.Track + for _, album := range artist.Albums { + album, err := server.GetAlbum(album.ID) + if err != nil { + log.Printf("error loading album tracks: %v", err.Error()) + return nil + } + allTracks = append(allTracks, album.Tracks...) + } + return allTracks +} + +func (m *Controller) PlayArtistDiscography(artistID string, shuffle bool) { + m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), false, shuffle) + m.App.PlaybackManager.PlayFromBeginning() +} + func (m *Controller) PromptForFirstServer() { d := dialogs.NewAddEditServerDialog("Connect to Server", false, nil, m.MainWindow.Canvas().Focus) pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) diff --git a/ui/dialogs/aboutdialog.go b/ui/dialogs/aboutdialog.go index 702f5d6..7e10ba8 100644 --- a/ui/dialogs/aboutdialog.go +++ b/ui/dialogs/aboutdialog.go @@ -94,14 +94,12 @@ func (a *AboutDialog) buildCreditsContainer() fyne.CanvasObject { freepikURL, _ := url.Parse("https://www.flaticon.com/authors/freepik") appIconCredit := widget.NewLabel("The Supersonic app icon is a derivative of a work created by Piotr Siedlecki and placed in the public domain.") appIconCredit.Wrapping = fyne.TextWrapWord - albumPlaceholderCredit := widget.NewLabel("The album cover placeholder image was created by @LaurenBacall originally for the MP3Tag project.") - albumPlaceholderCredit.Wrapping = fyne.TextWrapWord return container.New(&layouts.VboxCustomPadding{ExtraPad: -10}, widget.NewLabel("Major frameworks and modules used in this application include:"), container.NewHBox(widget.NewHyperlink("Fyne toolkit", fyneURL), widget.NewLabel("BSD 3-Clause License")), container.NewHBox(widget.NewHyperlink("go-subsonic", goSubsonicURL), widget.NewLabel("GPL v3 License")), container.NewHBox(widget.NewHyperlink("go-toml", goTomlURL), widget.NewLabel("MIT License")), - appIconCredit, albumPlaceholderCredit, + appIconCredit, container.NewHBox(widget.NewLabel("Additional icons by:"), widget.NewHyperlink("Freepik on flaticon.com", freepikURL)), ) } diff --git a/ui/theme/themedrectangle.go b/ui/theme/themedrectangle.go index 663274f..fcc0c34 100644 --- a/ui/theme/themedrectangle.go +++ b/ui/theme/themedrectangle.go @@ -11,7 +11,9 @@ type ThemedRectangle struct { rect *canvas.Rectangle - ColorName fyne.ThemeColorName + ColorName fyne.ThemeColorName + BorderWidth float32 + BorderColorName fyne.ThemeColorName } func NewThemedRectangle(colorName fyne.ThemeColorName) *ThemedRectangle { @@ -25,8 +27,11 @@ func NewThemedRectangle(colorName fyne.ThemeColorName) *ThemedRectangle { } func (t *ThemedRectangle) Refresh() { - t.rect.FillColor = fyne.CurrentApp().Settings().Theme().Color(t.ColorName, - fyne.CurrentApp().Settings().ThemeVariant()) + settings := fyne.CurrentApp().Settings() + theme := settings.Theme() + t.rect.FillColor = theme.Color(t.ColorName, settings.ThemeVariant()) + t.rect.StrokeWidth = t.BorderWidth + t.rect.StrokeColor = theme.Color(t.BorderColorName, settings.ThemeVariant()) t.BaseWidget.Refresh() } diff --git a/ui/widgets/artistgenrelist.go b/ui/widgets/artistgenrelist.go deleted file mode 100644 index e673bd6..0000000 --- a/ui/widgets/artistgenrelist.go +++ /dev/null @@ -1,116 +0,0 @@ -package widgets - -import ( - "strconv" - - "github.com/dweymouth/supersonic/ui/layouts" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" -) - -type ArtistGenreListItemModel struct { - ID string - Name string - AlbumCount int - TrackCount int - Favorite bool -} - -type ArtistGenreList struct { - widget.BaseWidget - - Items []ArtistGenreListItemModel - ShowAlbumCount bool - ShowTrackCount bool - OnNavTo func(string) - - columnsLayout *layouts.ColumnsLayout - hdr *ListHeader - list *widget.List - container *fyne.Container -} - -type ArtistGenreListRow struct { - widget.BaseWidget - - Item ArtistGenreListItemModel - OnTapped func() - - nameLabel *widget.Label - albumCountLabel *widget.Label - trackCountLabel *widget.Label - - container *fyne.Container -} - -func NewArtistGenreListRow(layout *layouts.ColumnsLayout) *ArtistGenreListRow { - a := &ArtistGenreListRow{ - nameLabel: widget.NewLabel(""), - albumCountLabel: widget.NewLabel(""), - trackCountLabel: widget.NewLabel(""), - } - a.ExtendBaseWidget(a) - a.albumCountLabel.Alignment = fyne.TextAlignTrailing - a.trackCountLabel.Alignment = fyne.TextAlignTrailing - a.container = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel) - return a -} - -func NewArtistGenreList(items []ArtistGenreListItemModel) *ArtistGenreList { - a := &ArtistGenreList{ - Items: items, - columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}), - } - a.ExtendBaseWidget(a) - a.hdr = NewListHeader([]ListColumn{ - {"Name", fyne.TextAlignLeading, false}, {"Album Count", fyne.TextAlignTrailing, false}, {"Track Count", fyne.TextAlignTrailing, false}}, a.columnsLayout) - a.hdr.DisableSorting = true - a.list = widget.NewList( - func() int { return len(a.Items) }, - func() fyne.CanvasObject { - r := NewArtistGenreListRow(a.columnsLayout) - r.OnTapped = func() { a.onRowDoubleTapped(r.Item) } - return r - }, - func(id widget.ListItemID, item fyne.CanvasObject) { - row := item.(*ArtistGenreListRow) - row.Item = a.Items[id] - row.albumCountLabel.Hidden = !a.ShowAlbumCount - row.trackCountLabel.Hidden = !a.ShowTrackCount - row.nameLabel.Text = row.Item.Name - row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount) - row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount) - row.Refresh() - }, - ) - a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list) - return a -} - -func (a *ArtistGenreList) Refresh() { - a.hdr.SetColumnVisible(1, a.ShowAlbumCount) - a.hdr.SetColumnVisible(2, a.ShowTrackCount) - a.BaseWidget.Refresh() -} - -func (a *ArtistGenreList) onRowDoubleTapped(item ArtistGenreListItemModel) { - if a.OnNavTo != nil { - a.OnNavTo(item.ID) - } -} - -func (a *ArtistGenreListRow) Tapped(*fyne.PointEvent) { - if a.OnTapped != nil { - a.OnTapped() - } -} - -func (a *ArtistGenreList) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(a.container) -} - -func (a *ArtistGenreListRow) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(a.container) -} diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index ec35f25..7e8c231 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -7,7 +7,6 @@ import ( "sync" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/sharedutil" "fyne.io/fyne/v2" @@ -81,6 +80,7 @@ type GridViewState struct { itemsMutex sync.RWMutex iter GridViewIterator imageFetcher ImageFetcher + placeholder fyne.Resource highestShown int fetchCancel context.CancelFunc done bool @@ -96,12 +96,13 @@ type GridViewState struct { var _ fyne.Widget = (*GridView)(nil) -func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher) *GridView { +func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ items: items, done: true, imageFetcher: fetch, + placeholder: placeholder, }, } g.ExtendBaseWidget(g) @@ -109,7 +110,7 @@ func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher) *GridView { return g } -func NewGridView(iter GridViewIterator, fetch ImageFetcher) *GridView { +func NewGridView(iter GridViewIterator, fetch ImageFetcher, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ iter: iter, @@ -117,7 +118,6 @@ func NewGridView(iter GridViewIterator, fetch ImageFetcher) *GridView { }, } g.ExtendBaseWidget(g) - g.createGridWrap() // fetch initial items @@ -174,6 +174,14 @@ func (g *GridView) ResetFixed(items []GridViewItemModel) { g.iter = nil } +func (g *GridView) GetScrollOffset() float32 { + return g.grid.GetScrollOffset() +} + +func (g *GridView) ScrollToOffset(offs float32) { + g.grid.ScrollToOffset(offs) +} + func (g *GridView) createGridWrap() { g.grid = NewGridWrap( func() int { @@ -181,7 +189,7 @@ func (g *GridView) createGridWrap() { }, // create func func() fyne.CanvasObject { - card := NewGridViewItem() + card := NewGridViewItem(g.placeholder) card.OnPlay = func(shuffle bool) { if g.OnPlay != nil { g.OnPlay(card.ItemID(), shuffle) @@ -244,7 +252,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok { card.Cover.SetImage(img) } else { - card.Cover.SetImageResource(res.ResAlbumplaceholderPng) + card.Cover.SetImage(nil) // asynchronously fetch cover image ctx, cancel := context.WithCancel(context.Background()) card.ImgLoadCancel = cancel @@ -263,8 +271,8 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { }(ctx) } } else { - // use the placeholder image for an item that has no cover art ID - card.Cover.SetImageResource(res.ResAlbumplaceholderPng) + // use the placeholder for an item that has no cover art ID + card.Cover.SetImage(nil) } // if user has scrolled near the bottom, fetch more diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index 6b237b5..1c91d84 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -17,27 +17,27 @@ import ( var _ fyne.Widget = (*GridViewItem)(nil) var _ fyne.Widget = (*coverImage)(nil) -var _ fyne.Tappable = (*coverImage)(nil) -var _ fyne.SecondaryTappable = (*coverImage)(nil) type coverImage struct { widget.BaseWidget - Im *canvas.Image + Im *ImagePlaceholder playbtn *canvas.Image OnPlay func() OnShowPage func() OnShowContextMenu func(fyne.Position) } -func newCoverImage() *coverImage { +func newCoverImage(placeholderResource fyne.Resource) *coverImage { c := &coverImage{} - c.ExtendBaseWidget(c) - c.Im = &canvas.Image{FillMode: canvas.ImageFillContain, ScaleMode: canvas.ImageScaleFastest} - c.Im.SetMinSize(fyne.NewSize(200, 200)) + c.Im = NewImagePlaceholder(placeholderResource, 200) + c.Im.OnTapped = c.Tapped + c.Im.OnTappedSecondary = c.TappedSecondary + c.Im.ScaleMode = canvas.ImageScaleFastest c.playbtn = &canvas.Image{FillMode: canvas.ImageFillContain, Resource: res.ResPlaybuttonPng} c.playbtn.SetMinSize(fyne.NewSize(60, 60)) c.playbtn.Hidden = true + c.ExtendBaseWidget(c) return c } @@ -93,15 +93,7 @@ func (a *coverImage) center() fyne.Position { } func (a *coverImage) SetImage(im image.Image) { - a.Im.Resource = nil - a.Im.Image = im - a.Refresh() -} - -func (a *coverImage) SetImageResource(res *fyne.StaticResource) { - a.Im.Image = nil - a.Im.Resource = res - a.Refresh() + a.Im.SetImage(im, true) } func isInside(origin fyne.Position, radius float32, point fyne.Position) bool { @@ -141,11 +133,11 @@ type GridViewItem struct { OnShowSecondaryPage func() } -func NewGridViewItem() *GridViewItem { +func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { g := &GridViewItem{ primaryText: NewCustomHyperlink(), secondaryText: NewCustomHyperlink(), - Cover: newCoverImage(), + Cover: newCoverImage(placeholderResource), } g.ExtendBaseWidget(g) g.Cover.OnPlay = func() { g.onPlay(false) } diff --git a/ui/widgets/imageplaceholder.go b/ui/widgets/imageplaceholder.go index 693886f..b113a0b 100644 --- a/ui/widgets/imageplaceholder.go +++ b/ui/widgets/imageplaceholder.go @@ -2,48 +2,52 @@ package widgets import ( "image" - "image/color" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + myTheme "github.com/dweymouth/supersonic/ui/theme" ) // A widget that can display an image or else // a placeholder with a rectangular border frame // and an icon positioned in the center of the frame. type ImagePlaceholder struct { + ScaleMode canvas.ImageScale + widget.BaseWidget - container *fyne.Container + content *fyne.Container + imageDisp *TappableImage image image.Image + iconImage *canvas.Image + border *myTheme.ThemedRectangle minSize float32 - OnTapped func() + OnTapped func(*fyne.PointEvent) + OnTappedSecondary func(*fyne.PointEvent) } func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder { - m := &ImagePlaceholder{minSize: minSize} - m.ExtendBaseWidget(m) - img := canvas.NewImageFromResource(centerIcon) - img.FillMode = canvas.ImageFillContain - img.SetMinSize(fyne.NewSize(minSize/4, minSize/4)) - rect := canvas.NewRectangle(theme.BackgroundColor()) - rect.StrokeColor = color.Black - rect.StrokeWidth = 3 - rect.SetMinSize(fyne.NewSize(minSize, minSize)) - m.container = container.NewMax( - rect, - container.NewCenter(img), + i := &ImagePlaceholder{minSize: minSize} + i.ExtendBaseWidget(i) + i.iconImage = canvas.NewImageFromResource(centerIcon) + i.iconImage.FillMode = canvas.ImageFillContain + i.iconImage.SetMinSize(fyne.NewSize(minSize/4, minSize/4)) + i.imageDisp = NewTappableImage(i.onTapped) + i.imageDisp.OnTappedSecondary = i.onTappedSecondary + i.imageDisp.FillMode = canvas.ImageFillContain + i.imageDisp.Hidden = true + i.border = myTheme.NewThemedRectangle(theme.ColorNameBackground) + i.border.BorderColorName = theme.ColorNameForeground + i.border.BorderWidth = 3 + i.content = container.NewMax( + i.border, + container.NewCenter(i.iconImage), + i.imageDisp, ) - return m -} - -type CanvasImage interface { - fyne.CanvasObject - - SetMinSize(fyne.Size) + return i } func (i *ImagePlaceholder) HaveImage() bool { @@ -51,34 +55,43 @@ func (i *ImagePlaceholder) HaveImage() bool { } func (i *ImagePlaceholder) SetImage(img image.Image, tappable bool) { - var cIm CanvasImage i.image = img - if tappable { - cImg := NewTappableImage(i.onTapped) - cImg.Image.Image = img - cImg.FillMode = canvas.ImageFillContain - cIm = cImg - } else { - cImg := canvas.NewImageFromImage(img) - cImg.FillMode = canvas.ImageFillContain - cIm = cImg + if img != nil { + i.imageDisp.DisableTapping = !tappable + i.imageDisp.Image.Image = img } - cIm.SetMinSize(fyne.NewSize(i.minSize, i.minSize)) - i.container.RemoveAll() - i.container.Add(cIm) - i.container.Refresh() + + i.Refresh() } func (i *ImagePlaceholder) Image() image.Image { return i.image } -func (i *ImagePlaceholder) onTapped() { +func (i *ImagePlaceholder) onTapped(e *fyne.PointEvent) { if i.OnTapped != nil { - i.OnTapped() + i.OnTapped(e) } } -func (i *ImagePlaceholder) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(i.container) +func (i *ImagePlaceholder) onTappedSecondary(e *fyne.PointEvent) { + if i.OnTappedSecondary != nil { + i.OnTappedSecondary(e) + } +} + +func (i *ImagePlaceholder) MinSize() fyne.Size { + return fyne.NewSize(i.minSize, i.minSize) +} + +func (i *ImagePlaceholder) Refresh() { + i.border.Hidden = i.HaveImage() + i.iconImage.Hidden = i.HaveImage() + i.imageDisp.Hidden = !i.HaveImage() + i.imageDisp.ScaleMode = i.ScaleMode + i.BaseWidget.Refresh() +} + +func (i *ImagePlaceholder) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(i.content) } diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index 7e057b0..b961e3a 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -55,7 +55,7 @@ func NewNowPlayingCard() *NowPlayingCard { return n } -func (n *NowPlayingCard) onShowCoverImage() { +func (n *NowPlayingCard) onShowCoverImage(*fyne.PointEvent) { if n.OnShowCoverImage != nil { n.OnShowCoverImage() } @@ -107,7 +107,7 @@ func (n *NowPlayingCard) OnTrackNameTapped(f func()) { n.trackName.OnTapped = f } -func (n *NowPlayingCard) showMenu(pos fyne.Position) { +func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) { if n.menu == nil { ratingMenu := util.NewRatingSubmenu(n.onSetRating) m := fyne.NewMenu("", @@ -117,5 +117,5 @@ func (n *NowPlayingCard) showMenu(pos fyne.Position) { fyne.NewMenuItem("Add to playlist...", func() { n.onAddToPlaylist() })) n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n)) } - n.menu.ShowAtPosition(pos) + n.menu.ShowAtPosition(e.AbsolutePosition) } diff --git a/ui/widgets/tappablewrappers.go b/ui/widgets/tappablewrappers.go index 89b99d1..8f8df88 100644 --- a/ui/widgets/tappablewrappers.go +++ b/ui/widgets/tappablewrappers.go @@ -54,11 +54,12 @@ type TappableImage struct { widget.BaseWidget canvas.Image - OnTapped func() - OnTappedSecondary func(pos fyne.Position) + DisableTapping bool + OnTapped func(*fyne.PointEvent) + OnTappedSecondary func(*fyne.PointEvent) } -func NewTappableImage(onTapped func()) *TappableImage { +func NewTappableImage(onTapped func(*fyne.PointEvent)) *TappableImage { t := &TappableImage{OnTapped: onTapped} t.ExtendBaseWidget(t) return t @@ -85,21 +86,21 @@ func (t *TappableImage) Resize(size fyne.Size) { } func (t *TappableImage) Cursor() desktop.Cursor { - if t.haveImage() { + if !t.DisableTapping && t.haveImage() { return desktop.PointerCursor } return desktop.DefaultCursor } -func (t *TappableImage) Tapped(_ *fyne.PointEvent) { - if t.haveImage() && t.OnTapped != nil { - t.OnTapped() +func (t *TappableImage) Tapped(e *fyne.PointEvent) { + if !t.DisableTapping && t.haveImage() && t.OnTapped != nil { + t.OnTapped(e) } } func (t *TappableImage) TappedSecondary(e *fyne.PointEvent) { - if t.haveImage() && t.OnTappedSecondary != nil { - t.OnTappedSecondary(e.AbsolutePosition) + if !t.DisableTapping && t.haveImage() && t.OnTappedSecondary != nil { + t.OnTappedSecondary(e) } }