Skip to content

violet-dev/arcacon-downloader

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 

Repository files navigation

arcacon-downloader

arcacon-downloader

import 'dart:io';

import 'package:arcacon/download/download_task.dart';
import 'package:arcacon/download/native_downloader.dart';
import 'package:arcacon/other/dialogs.dart';
import 'package:arcacon/other/html/parser.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:ext_storage/ext_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ffmpeg/flutter_ffmpeg.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:http/http.dart' as http;
import 'package:video_player/video_player.dart';
import 'package:path/path.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  var appdir = await getApplicationDocumentsDirectory();
  if (Platform.isAndroid) {
    if (!appdir.path.contains('/com.violet.arcacondownloader/')) return;
  }

  runApp(
    MaterialApp(
      title: '아카콘 다운로더',
      home: MainPage(),
      theme:
          ThemeData(brightness: Brightness.dark, primarySwatch: Colors.orange),
    ),
  );
}

class MainPage extends StatefulWidget {
  @override
  _MainPageState createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  TextEditingController textEditingController = TextEditingController();
  int _value = 0;
  int _sort = 0;
  String curUrl = 'https://arca.live/e/?';
  int _page = 0;
  List<Arcacon> _lists = List<Arcacon>();
  Map<String, VideoPlayerController> _videos =
      Map<String, VideoPlayerController>();
  ScrollController _controller = ScrollController();
  bool scrollOnce = false;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) => _load());

    _controller.addListener(() {
      if (scrollOnce) return;
      if (_controller.offset > _controller.position.maxScrollExtent / 4 * 3) {
        scrollOnce = true;
        Future.delayed(Duration(milliseconds: 100)).then((value) async {
          _page++;
          await _load();
          scrollOnce = false;
        });
      }
    });
  }

  @override
  void didChangeDependencies() async {
    super.didChangeDependencies();

    if (await Permission.storage.isPermanentlyDenied ||
        await Permission.storage.isUndetermined ||
        await Permission.storage.isDenied) {
      if (await Permission.storage.request() == PermissionStatus.denied) {
        await Dialogs.okDialog(
            this.context, "저장공간 권한을 허용하지 않으면 앱을 이용할 수 없습니다.");
        if (Platform.isAndroid)
          SystemNavigator.pop();
        else if (Platform.isIOS) exit(0);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('아카콘 다운로더'),
        actions: [
          IconButton(
            padding: EdgeInsets.zero,
            icon: Text(
              _sort == 0 ? '등록순' : '판매순',
              style: TextStyle(fontSize: 16.0),
            ),
            onPressed: () async {
              setState(() {
                _sort = 1 - _sort;
                _page = 0;
                _lists.clear();
              });
              await _load();
            },
          ),
          IconButton(
            icon: Icon(Icons.info_outline),
            onPressed: () async {
              await showDialog(
                context: context,
                builder: (BuildContext context) {
                  return InfoPage();
                },
              );
            },
          ),
        ],
      ),
      body: CustomScrollView(
        physics: const BouncingScrollPhysics(),
        controller: _controller,
        slivers: <Widget>[
          SliverPersistentHeader(
            floating: true,
            delegate: AnimatedOpacitySliver(
              minExtent: 64 + 12.0,
              maxExtent: 64.0 + 12,
              searchBar: Padding(
                padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
                child: Card(
                  child: Padding(
                    padding: EdgeInsets.symmetric(horizontal: 16.0),
                    child: Row(
                      children: [
                        Icon(Icons.search),
                        Container(width: 8.0),
                        DropdownButton(
                            value: _value,
                            items: [
                              DropdownMenuItem(
                                child: Text("제목",
                                    style: TextStyle(fontSize: 20.0)),
                                value: 0,
                              ),
                              DropdownMenuItem(
                                child: Text("판매자",
                                    style: TextStyle(fontSize: 20.0)),
                                value: 1,
                              ),
                            ],
                            onChanged: (value) {
                              setState(() {
                                _value = value;
                              });
                            }),
                        Container(width: 8.0),
                        Expanded(
                          child: TextField(
                            controller: textEditingController,
                            textInputAction: TextInputAction.go,
                            decoration: new InputDecoration(
                              hintText: "검색어를 입력하세요",
                              contentPadding: const EdgeInsets.all(0.0),
                              focusedBorder: InputBorder.none,
                              enabledBorder: InputBorder.none,
                              errorBorder: InputBorder.none,
                              disabledBorder: InputBorder.none,
                            ),
                            style: TextStyle(fontSize: 20.0),
                            onEditingComplete: () async {
                              FocusScope.of(context).unfocus();

                              var url = 'https://arca.live/e/?keyword=' +
                                  Uri.encodeFull(textEditingController.text);

                              if (_value == 0)
                                url += '&target=title';
                              else if (_value == 1) url += '&target=nickname';

                              curUrl = url;

                              _page = 0;
                              _lists.clear();

                              await _load();
                            },
                          ),
                        ),
                        IconButton(
                          icon: Icon(Icons.arrow_back),
                          onPressed: () {
                            textEditingController.text = '';
                          },
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
          SliverPadding(
            padding: EdgeInsets.fromLTRB(12, 0, 12, 16),
            sliver: SliverGrid(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 8,
                mainAxisSpacing: 8,
                childAspectRatio: 1,
              ),
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  var e = _lists[index];

                  if (e.image.endsWith('.mp4')) {
                    if (!_videos.containsKey(e.image)) {
                      var vc = VideoPlayerController.network(e.image);
                      _videos[e.image] = vc;
                      vc.initialize().then((value) async {
                        await _videos[e.image].setLooping(true);
                        Future.delayed(Duration(milliseconds: 1000)).then(
                            (value) async => await _videos[e.image].play());
                        setState(() {});
                      });
                    } else {
                      Future.delayed(Duration(milliseconds: 500))
                          .then((value) async => await _videos[e.image].play());
                    }
                  }

                  return Card(
                    child: InkWell(
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          !e.image.endsWith('.mp4')
                              ? CachedNetworkImage(imageUrl: e.image)
                              : _videos[e.image].value.initialized
                                  ? Container(
                                      height: 100.0,
                                      child: AspectRatio(
                                        aspectRatio:
                                            _videos[e.image].value.aspectRatio,
                                        child: VideoPlayer(
                                          _videos[e.image],
                                        ),
                                      ))
                                  : Container(),
                          Text(
                            e.title,
                            textAlign: TextAlign.center,
                            style: TextStyle(fontWeight: FontWeight.bold),
                          ),
                          Padding(
                            padding: EdgeInsets.symmetric(horizontal: 4.0),
                            child: Text(
                              e.uploader,
                              softWrap: true,
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                              textAlign: TextAlign.center,
                            ),
                          ),
                          Text(
                            e.count,
                            textAlign: TextAlign.center,
                          ),
                        ],
                      ),
                      onTap: () async {
                        // Navigator.push(
                        //   context,
                        //   MaterialPageRoute(
                        //       builder: (context) => ArcaconPage(
                        //             arcacon: e,
                        //           )),
                        // );
                        // showDialog(context: null)

                        var dir = (await getTemporaryDirectory());

                        var files = await showDialog(
                          context: context,
                          builder: (BuildContext context) {
                            return DownloadPage(
                              arcacon: e,
                              basePath: dir.path,
                            );
                          },
                        );

                        Navigator.push(
                          context,
                          MaterialPageRoute(
                              builder: (context) => ArcaconPage(
                                    arcacon: e,
                                    files: files,
                                  )),
                        );
                      },
                    ),
                  );
                },
                childCount: _lists.length,
              ),
            ),
          ),
        ],
      ),
    );
  }

  _load() async {
    var url = curUrl;
    if (_sort == 1) url = 'https://arca.live/e/?sort=rank';
    url += '&p=' + (_page + 1).toString();

    var html = (await http.get(url)).body;
    _lists.addAll(await ArcaliveParser.parseArcaconList(html));

    setState(() {});
  }
}

class DownloadPage extends StatefulWidget {
  final Arcacon arcacon;
  final String basePath;

  DownloadPage({this.arcacon, this.basePath});

  @override
  _DownloadPageState createState() => _DownloadPageState();
}

class _DownloadPageState extends State<DownloadPage> {
  int downloadedFileCount = 0;
  bool isGif = false;
  int gifCount = 0;
  int gifComplete = 0;
  List<String> _contents = List<String>();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    Future.delayed(Duration(milliseconds: 100)).then((value) async {
      var html = (await http.get(widget.arcacon.url)).body;
      _contents = await ArcaliveParser.parseArcacon(html);

      var path = widget.basePath;

      if (!Directory(path).existsSync()) await Directory(path).create();

      var downloader = await NativeDownloader.getInstance();

      var tasks = List<DownloadTask>.generate(
          _contents.length,
          (i) => DownloadTask(
              url: _contents[i],
              filename: i.toString().padLeft(2, '0') +
                  '.' +
                  _contents[i].split('.').last));

      await downloader.addTasks(tasks.map((e) {
        e.downloadPath = join(
            join(path, widget.arcacon.title.replaceAll('/', ' '), e.filename));

        e.startCallback = () {};
        e.completeCallback = () {
          setState(() {
            downloadedFileCount++;
          });
        };

        return e;
      }).toList());

      while (tasks.length != downloadedFileCount) {
        await Future.delayed(Duration(milliseconds: 500));
      }

      for (int i = 0; i < tasks.length; i++) {
        var element = tasks[i];
        if (element.url.endsWith('.mp4')) {
          gifCount++;
          isGif = true;
        }
      }

      setState(() {});

      for (int i = 0; i < tasks.length; i++) {
        var element = tasks[i];
        var fn = join(join(
            path, widget.arcacon.title.replaceAll('/', ' '), element.filename));
        if (element.url.endsWith('.mp4')) {
          await FlutterFFmpeg().executeWithArguments([
            '-y',
            '-i',
            fn,
            '-loop',
            '0',
            fn.replaceAll('.mp4', '.gif'),
          ]);
          setState(() {
            gifComplete++;
          });
        }
      }

      for (int i = 0; i < tasks.length; i++) {
        var element = tasks[i];
        var fn = join(join(
            path, widget.arcacon.title.replaceAll('/', ' '), element.filename));
        if (element.url.endsWith('.mp4')) {
          await File(fn).delete();
        }
      }

      Navigator.pop(
          this.context,
          tasks
              .map((e) => join(join(path,
                      widget.arcacon.title.replaceAll('/', ' '), e.filename))
                  .replaceAll('.mp4', '.gif'))
              .toList());
    });
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        return false;
      },
      child: Container(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Card(
              color: Color(0xFF353535),
              child: SizedBox(
                child: Container(
                  padding: EdgeInsets.fromLTRB(20, 40, 20, 20),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      SizedBox(
                        child: CircularProgressIndicator(),
                        width: 50,
                        height: 50,
                      ),
                      Container(
                        height: 16,
                      ),
                      Text(isGif
                          ? '변환중...[$gifComplete/$gifCount]'
                          : '다운로드 중...[$downloadedFileCount/${_contents.length}]'),
                    ],
                  ),
                  width: 180,
                  // height: 190,
                ),
              ),
            ),
          ],
        ),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.all(Radius.circular(1)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.4),
              spreadRadius: 1,
              blurRadius: 1,
              offset: Offset(0, 3), // changes position of shadow
            ),
          ],
        ),
      ),
    );
  }
}

class ArcaconPage extends StatefulWidget {
  final Arcacon arcacon;
  final List<String> files;

  ArcaconPage({this.arcacon, this.files});

  @override
  _ArcaconPageState createState() => _ArcaconPageState();
}

class _ArcaconPageState extends State<ArcaconPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.arcacon.title),
        actions: [
          IconButton(
            icon: Icon(Icons.file_download),
            onPressed: () async {
              var dir = join(
                  await ExtStorage.getExternalStoragePublicDirectory(
                      ExtStorage.DIRECTORY_DOWNLOADS),
                  '아카콘');
              await showDialog(
                context: context,
                builder: (BuildContext context) {
                  return DownloadPage(
                    arcacon: widget.arcacon,
                    basePath: dir,
                  );
                },
              );

              Dialogs.okDialog(context, '다운로드 완료!');
            },
          )
        ],
      ),
      body: CustomScrollView(
        physics: BouncingScrollPhysics(),
        // primary: false,
        slivers: <Widget>[
          SliverGrid(
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 4,
              // crossAxisSpacing: 8,
              // mainAxisSpacing: 8,
              childAspectRatio: 1,
            ),
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                return Image.file(File(widget.files[index]));
              },
              childCount: widget.files.length,
            ),
          ),
        ],
      ),
    );
  }
}

class InfoPage extends StatelessWidget {
  Color getColor(int i) {
    return Colors.grey.shade400;
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.pop(context);
      },
      child: Container(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Card(
              color: Color(0xFF353535),
              child: SizedBox(
                child: Container(
                  padding: EdgeInsets.fromLTRB(20, 0, 20, 0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(''),
                      Text('아카콘 다운로더',
                          style: TextStyle(
                              fontSize: 30, fontWeight: FontWeight.bold)),
                      Text('2020.10.26', style: TextStyle(fontSize: 20)),
                      Text('by Koromo', style: TextStyle(fontSize: 20)),
                      Text(''),
                      // Text('Project-Violet Android App'),
                      // Text(
                      //   Translations.of(context).trans('infomessage'),
                      //   textAlign: TextAlign.center,
                      //   style: TextStyle(fontSize: 10),
                      // ),
                    ],
                  ),
                  width: 250,
                  // height: 190,
                ),
              ),
            ),
          ],
        ),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.all(Radius.circular(1)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.4),
              spreadRadius: 1,
              blurRadius: 1,
              offset: Offset(0, 3), // changes position of shadow
            ),
          ],
        ),
      ),
    );
  }
}

class AnimatedOpacitySliver implements SliverPersistentHeaderDelegate {
  AnimatedOpacitySliver(
      {this.minExtent, @required this.maxExtent, this.searchBar});
  final double minExtent;
  final double maxExtent;

  Widget searchBar;

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return searchBar;
    // return Stack(
    //   fit: StackFit.expand,
    //   children: [
    //     AnimatedOpacity(
    //       child: searchBar,
    //       opacity: 1.0 - max(0.0, shrinkOffset - 20) / (maxExtent - 20),
    //       duration: Duration(milliseconds: 100),
    //     )
    //   ],
    // );
  }

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }

  @override
  FloatingHeaderSnapConfiguration get snapConfiguration => null;

  @override
  OverScrollHeaderStretchConfiguration get stretchConfiguration => null;
}

class Arcacon {
  String image;
  String url;
  String title;
  String count;
  String uploader;

  Arcacon({this.image, this.uploader, this.url, this.count, this.title});
}

class ArcaliveParser {
  static Future<List<Arcacon>> parseArcaconList(String html) async {
    var doc = (await compute(parse, html))
        .querySelector(
            'body > div > div.content-wrapper.clearfix > article > div > div > div.emoticon-list')
        .querySelectorAll('a')
        .where((element) => element.attributes['href'].contains('p='))
        .toList();

    return doc
        .map((e) => Arcacon(
              url: 'https://arca.live' + e.attributes['href'],
              title: e.querySelector('div.title').text,
              count: e.querySelector('div.count').text,
              uploader: e.querySelector('div.maker').text,
              image: 'https:' +
                  (e.querySelector('img') == null
                      ? e.querySelector('video').attributes['src']
                      : e.querySelector('img').attributes['src']),
            ))
        .toList();
  }

  static Future<List<String>> parseArcacon(String html) async {
    return (await compute(parse, html))
        .querySelector(
            'body > div > div.content-wrapper.clearfix > article > div > div.article-wrapper > div.article-body')
        .children
        .where((element) => element.attributes.containsKey('src'))
        .map((element) => 'https:' + element.attributes['src'])
        .toList();
  }
}