Compare commits

..

No commits in common. "255839a598c402adafd519bef30fa3a195da9868" and "488e4094c8384b022904b3a52cd8a58a8117e94a" have entirely different histories.

77 changed files with 250 additions and 3375 deletions

View file

@ -10,9 +10,6 @@ RUN npm run build
FROM node:20-alpine
WORKDIR /app
# ffmpeg 설치 (비디오 썸네일 추출용)
RUN apk add --no-cache ffmpeg
# 백엔드 의존성 설치
COPY backend/package*.json ./
RUN npm install --production

View file

@ -1,11 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:usesCleartextTraffic="true"
android:label="fromis9"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
@ -31,16 +25,6 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- flutter_downloader provider -->
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="${applicationId}.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,4 +0,0 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,4 +0,0 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,3 +0,0 @@
<resources>
<color name="ic_launcher_background">#960000</color>
</resources>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View file

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View file

@ -1,128 +1,122 @@
{
"images": [
{
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "Icon-App-20x20@2x.png"
},
{
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "Icon-App-20x20@3x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "1x",
"filename": "Icon-App-29x29@1x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "Icon-App-29x29@2x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "Icon-App-29x29@3x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "Icon-App-40x40@2x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "Icon-App-40x40@3x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "Icon-App-60x60@2x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "Icon-App-60x60@3x.png"
},
{
"idiom": "iphone",
"size": "76x76",
"scale": "2x",
"filename": "Icon-App-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "Icon-App-20x20@1x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "Icon-App-20x20@2x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "Icon-App-29x29@1x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "Icon-App-29x29@2x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "Icon-App-40x40@1x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "Icon-App-40x40@2x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "Icon-App-76x76@1x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "Icon-App-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "Icon-App-83.5x83.5@2x.png"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"scale": "1x",
"filename": "ItunesArtwork@2x.png"
}
],
"info": {
"version": 1,
"author": "easyappicon"
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,015 B

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,015 B

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View file

@ -45,7 +45,5 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>사진을 갤러리에 저장하기 위해 권한이 필요합니다.</string>
</dict>
</plist>

View file

@ -7,8 +7,6 @@ import '../views/main_shell.dart';
import '../views/home/home_view.dart';
import '../views/members/members_view.dart';
import '../views/album/album_view.dart';
import '../views/album/album_detail_view.dart';
import '../views/album/track_detail_view.dart';
import '../views/schedule/schedule_view.dart';
///
@ -49,27 +47,5 @@ final GoRouter appRouter = GoRouter(
),
],
),
// ( )
GoRoute(
path: '/album/:name',
parentNavigatorKey: rootNavigatorKey,
builder: (context, state) {
final albumName = state.pathParameters['name']!;
return AlbumDetailView(albumName: albumName);
},
),
// ( )
GoRoute(
path: '/album/:albumName/track/:trackTitle',
parentNavigatorKey: rootNavigatorKey,
builder: (context, state) {
final albumName = state.pathParameters['albumName']!;
final trackTitle = state.pathParameters['trackTitle']!;
return TrackDetailView(
albumName: albumName,
trackTitle: trackTitle,
);
},
),
],
);

View file

@ -12,14 +12,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router.dart';
import 'core/constants.dart';
import 'services/download_service.dart';
void main() async {
void main() {
WidgetsFlutterBinding.ensureInitialized();
//
await initDownloadService();
//
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(

View file

@ -12,9 +12,6 @@ class Album {
final String? coverThumbUrl;
final String? folderName;
final String? description;
final List<Track>? tracks;
final List<Teaser>? teasers;
final Map<String, List<ConceptPhoto>>? conceptPhotos;
Album({
required this.id,
@ -27,42 +24,12 @@ class Album {
this.coverThumbUrl,
this.folderName,
this.description,
this.tracks,
this.teasers,
this.conceptPhotos,
});
factory Album.fromJson(Map<String, dynamic> json) {
//
List<Track>? tracks;
if (json['tracks'] != null) {
tracks = (json['tracks'] as List)
.map((t) => Track.fromJson(t))
.toList();
}
//
List<Teaser>? teasers;
if (json['teasers'] != null) {
teasers = (json['teasers'] as List)
.map((t) => Teaser.fromJson(t))
.toList();
}
//
Map<String, List<ConceptPhoto>>? conceptPhotos;
if (json['conceptPhotos'] != null) {
conceptPhotos = {};
(json['conceptPhotos'] as Map<String, dynamic>).forEach((key, value) {
conceptPhotos![key] = (value as List)
.map((p) => ConceptPhoto.fromJson(p))
.toList();
});
}
return Album(
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
id: json['id'] as int,
title: json['title'] as String,
albumType: json['album_type'] as String?,
albumTypeShort: json['album_type_short'] as String?,
releaseDate: json['release_date'] as String?,
@ -71,185 +38,9 @@ class Album {
coverThumbUrl: json['cover_thumb_url'] as String?,
folderName: json['folder_name'] as String?,
description: json['description'] as String?,
tracks: tracks,
teasers: teasers,
conceptPhotos: conceptPhotos,
);
}
///
String? get releaseYear => releaseDate?.substring(0, 4);
///
String get totalDuration {
if (tracks == null || tracks!.isEmpty) return '';
int totalSeconds = 0;
for (final track in tracks!) {
if (track.duration != null) {
final parts = track.duration!.split(':');
if (parts.length == 2) {
totalSeconds += int.parse(parts[0]) * 60 + int.parse(parts[1]);
}
}
}
final mins = totalSeconds ~/ 60;
final secs = totalSeconds % 60;
return '$mins:${secs.toString().padLeft(2, '0')}';
}
///
List<ConceptPhoto> get allConceptPhotos {
if (conceptPhotos == null) return [];
return conceptPhotos!.values.expand((list) => list).toList();
}
}
///
class Track {
final int id;
final int trackNumber;
final String title;
final String? duration;
final int isTitleTrack;
Track({
required this.id,
required this.trackNumber,
required this.title,
this.duration,
this.isTitleTrack = 0,
});
factory Track.fromJson(Map<String, dynamic> json) {
return Track(
id: (json['id'] as num?)?.toInt() ?? 0,
trackNumber: (json['track_number'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
duration: json['duration'] as String?,
isTitleTrack: (json['is_title_track'] as num?)?.toInt() ?? 0,
);
}
}
///
class Teaser {
final int id;
final String? originalUrl;
final String? thumbUrl;
final String? videoUrl;
final String? mediaType;
Teaser({
required this.id,
this.originalUrl,
this.thumbUrl,
this.videoUrl,
this.mediaType,
});
factory Teaser.fromJson(Map<String, dynamic> json) {
return Teaser(
id: (json['id'] as num?)?.toInt() ?? 0,
originalUrl: json['original_url'] as String?,
thumbUrl: json['thumb_url'] as String?,
videoUrl: json['video_url'] as String?,
mediaType: json['media_type'] as String?,
);
}
}
///
class ConceptPhoto {
final int id;
final String? originalUrl;
final String? mediumUrl;
final String? thumbUrl;
ConceptPhoto({
required this.id,
this.originalUrl,
this.mediumUrl,
this.thumbUrl,
});
factory ConceptPhoto.fromJson(Map<String, dynamic> json) {
return ConceptPhoto(
id: (json['id'] as num?)?.toInt() ?? 0,
originalUrl: json['original_url'] as String?,
mediumUrl: json['medium_url'] as String?,
thumbUrl: json['thumb_url'] as String?,
);
}
}
/// ( )
class TrackDetail {
final int id;
final int trackNumber;
final String title;
final String? duration;
final int isTitleTrack;
final String? lyricist;
final String? composer;
final String? arranger;
final String? lyrics;
final String? musicVideoUrl;
final TrackAlbum? album;
TrackDetail({
required this.id,
required this.trackNumber,
required this.title,
this.duration,
this.isTitleTrack = 0,
this.lyricist,
this.composer,
this.arranger,
this.lyrics,
this.musicVideoUrl,
this.album,
});
factory TrackDetail.fromJson(Map<String, dynamic> json) {
return TrackDetail(
id: (json['id'] as num?)?.toInt() ?? 0,
trackNumber: (json['track_number'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
duration: json['duration'] as String?,
isTitleTrack: (json['is_title_track'] as num?)?.toInt() ?? 0,
lyricist: json['lyricist'] as String?,
composer: json['composer'] as String?,
arranger: json['arranger'] as String?,
lyrics: json['lyrics'] as String?,
musicVideoUrl: json['music_video_url'] as String?,
album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null,
);
}
}
///
class TrackAlbum {
final int id;
final String title;
final String? albumType;
final String? coverMediumUrl;
final String? folderName;
TrackAlbum({
required this.id,
required this.title,
this.albumType,
this.coverMediumUrl,
this.folderName,
});
factory TrackAlbum.fromJson(Map<String, dynamic> json) {
return TrackAlbum(
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
albumType: json['album_type'] as String?,
coverMediumUrl: json['cover_medium_url'] as String?,
folderName: json['folder_name'] as String?,
);
}
}

View file

@ -16,17 +16,3 @@ Future<List<Album>> getRecentAlbums(int count) async {
final albums = await getAlbums();
return albums.take(count).toList();
}
/// ()
Future<Album> getAlbumByName(String name) async {
final response = await dio.get('/albums/by-name/$name');
return Album.fromJson(response.data);
}
/// (, )
Future<TrackDetail> getTrack(String albumName, String trackTitle) async {
final encodedAlbum = Uri.encodeComponent(albumName);
final encodedTrack = Uri.encodeComponent(trackTitle);
final response = await dio.get('/albums/by-name/$encodedAlbum/track/$encodedTrack');
return TrackDetail.fromJson(response.data);
}

View file

@ -1,54 +0,0 @@
///
library;
import 'dart:io';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
///
Future<void> initDownloadService() async {
await FlutterDownloader.initialize(
debug: false,
ignoreSsl: true,
);
}
///
Future<String?> downloadImage(String url, {String? fileName}) async {
//
if (Platform.isAndroid) {
final status = await Permission.notification.request();
if (status.isDenied) {
//
}
}
//
Directory? directory;
if (Platform.isAndroid) {
directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
directory = await getExternalStorageDirectory();
}
} else {
directory = await getApplicationDocumentsDirectory();
}
if (directory == null) return null;
//
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg';
//
final taskId = await FlutterDownloader.enqueue(
url: url,
savedDir: directory.path,
fileName: name,
showNotification: true,
openFileFromNotification: true,
);
return taskId;
}

File diff suppressed because it is too large Load diff

View file

@ -1,268 +1,41 @@
///
///
library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/constants.dart';
import '../../models/album.dart';
import '../../services/albums_service.dart';
class AlbumView extends StatefulWidget {
class AlbumView extends StatelessWidget {
const AlbumView({super.key});
@override
State<AlbumView> createState() => _AlbumViewState();
}
class _AlbumViewState extends State<AlbumView> {
late Future<List<Album>> _albumsFuture;
bool _initialLoadComplete = false;
@override
void initState() {
super.initState();
_albumsFuture = getAlbums();
//
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) {
setState(() => _initialLoadComplete = true);
}
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.alertCircle,
size: 48,
color: AppColors.textTertiary,
),
const SizedBox(height: 16),
Text(
'앨범을 불러오는데 실패했습니다',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() {
_albumsFuture = getAlbums();
});
},
child: const Text('다시 시도'),
),
],
),
);
}
final albums = snapshot.data ?? [];
return GridView.builder(
padding: const EdgeInsets.all(16),
clipBehavior: Clip.none,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.album_outlined,
size: 64,
color: AppColors.textTertiary,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return _AlbumCard(
album: album,
index: index,
skipAnimation: _initialLoadComplete,
);
},
);
},
);
}
}
class _AlbumCard extends StatefulWidget {
final Album album;
final int index;
final bool skipAnimation;
const _AlbumCard({
required this.album,
required this.index,
this.skipAnimation = false,
});
@override
State<_AlbumCard> createState() => _AlbumCardState();
}
class _AlbumCardState extends State<_AlbumCard>
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<Offset> _slideAnimation;
bool _hasAnimated = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// skipAnimation이면
if (widget.skipAnimation) {
_hasAnimated = true;
_controller.value = 1.0;
} else {
// ( 8 , )
final delay = widget.index < 8 ? widget.index * 50 : 400;
Future.delayed(Duration(milliseconds: delay), () {
if (mounted && !_hasAnimated) {
_hasAnimated = true;
_controller.forward();
}
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return FadeTransition(
opacity: _opacityAnimation,
child: SlideTransition(
position: _slideAnimation,
child: GestureDetector(
onTap: () {
final folderName = widget.album.folderName;
if (folderName != null && folderName.isNotEmpty) {
context.push('/album/$folderName');
}
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
child: Container(
width: double.infinity,
color: AppColors.divider,
child: widget.album.coverThumbUrl != null
? CachedNetworkImage(
imageUrl: widget.album.coverThumbUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: AppColors.divider,
),
errorWidget: (context, url, error) => const Center(
child: Icon(
LucideIcons.disc3,
size: 48,
color: AppColors.textTertiary,
),
),
)
: const Center(
child: Icon(
LucideIcons.disc3,
size: 48,
color: AppColors.textTertiary,
),
),
),
),
),
//
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.album.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${widget.album.albumTypeShort ?? ''} · ${widget.album.releaseYear ?? ''}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
),
),
],
),
),
],
SizedBox(height: 16),
Text(
'앨범',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textSecondary,
),
),
),
SizedBox(height: 8),
Text(
'앨범 화면 준비 중',
style: TextStyle(
fontSize: 14,
color: AppColors.textTertiary,
),
),
],
),
);
}

View file

@ -1,668 +0,0 @@
///
library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/constants.dart';
import '../../models/album.dart';
import '../../services/albums_service.dart';
class TrackDetailView extends StatefulWidget {
final String albumName;
final String trackTitle;
const TrackDetailView({
super.key,
required this.albumName,
required this.trackTitle,
});
@override
State<TrackDetailView> createState() => _TrackDetailViewState();
}
class _TrackDetailViewState extends State<TrackDetailView> {
late Future<TrackDetail> _trackFuture;
bool _showFullLyrics = false;
@override
void initState() {
super.initState();
_trackFuture = getTrack(widget.albumName, widget.trackTitle);
}
/// YouTube URL에서 ID
String? _getYoutubeVideoId(String? url) {
if (url == null) return null;
final regex = RegExp(
r'(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)',
);
final match = regex.firstMatch(url);
return match?.group(1);
}
///
List<String> _parseCredit(String? text) {
if (text == null || text.isEmpty) return [];
return text.split(',').map((s) => s.trim()).toList();
}
/// YouTube
Future<void> _openYoutube(String videoId) async {
final url = Uri.parse('https://www.youtube.com/watch?v=$videoId');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: FutureBuilder<TrackDetail>(
future: _trackFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (snapshot.hasError || !snapshot.hasData) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.alertCircle, size: 48, color: AppColors.textTertiary),
const SizedBox(height: 16),
const Text('트랙을 찾을 수 없습니다'),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.pop(),
child: const Text('뒤로 가기'),
),
],
),
);
}
final track = snapshot.data!;
final youtubeVideoId = _getYoutubeVideoId(track.musicVideoUrl);
return CustomScrollView(
slivers: [
//
SliverAppBar(
pinned: true,
backgroundColor: Colors.white,
foregroundColor: AppColors.textPrimary,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.pop(),
),
title: const Text(
'트랙',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
//
SliverToBoxAdapter(
child: _TrackHeader(track: track),
),
//
if (youtubeVideoId != null)
SliverToBoxAdapter(
child: _MusicVideoSection(
videoId: youtubeVideoId,
trackTitle: track.title,
onTap: () => _openYoutube(youtubeVideoId),
),
),
//
if (track.lyricist != null || track.composer != null || track.arranger != null)
SliverToBoxAdapter(
child: _CreditSection(
lyricist: _parseCredit(track.lyricist),
composer: _parseCredit(track.composer),
arranger: _parseCredit(track.arranger),
),
),
//
SliverToBoxAdapter(
child: _LyricsSection(
lyrics: track.lyrics,
showFull: _showFullLyrics,
onToggle: () => setState(() => _showFullLyrics = !_showFullLyrics),
),
),
//
SliverToBoxAdapter(
child: SizedBox(height: 16 + MediaQuery.of(context).padding.bottom),
),
],
);
},
),
);
}
}
///
class _TrackHeader extends StatelessWidget {
final TrackDetail track;
const _TrackHeader({required this.track});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: track.album?.coverMediumUrl != null
? CachedNetworkImage(
imageUrl: track.album!.coverMediumUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: AppColors.divider),
errorWidget: (context, url, error) => Container(
color: AppColors.divider,
child: const Icon(LucideIcons.disc3, color: AppColors.textTertiary),
),
)
: Container(
color: AppColors.divider,
child: const Icon(LucideIcons.disc3, color: AppColors.textTertiary),
),
),
),
const SizedBox(width: 16),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// &
Row(
children: [
if (track.isTitleTrack == 1) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'TITLE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(width: 8),
],
Text(
'Track ${track.trackNumber.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
),
),
],
),
const SizedBox(height: 6),
//
Text(
track.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
//
Text(
'${track.album?.albumType ?? ''} · ${track.album?.title ?? ''}',
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
//
if (track.duration != null)
Row(
children: [
const Icon(LucideIcons.clock, size: 14, color: AppColors.textTertiary),
const SizedBox(width: 4),
Text(
track.duration!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
],
),
),
],
),
);
}
}
///
class _MusicVideoSection extends StatelessWidget {
final String videoId;
final String trackTitle;
final VoidCallback onTap;
const _MusicVideoSection({
required this.videoId,
required this.trackTitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'뮤직비디오',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
//
GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg',
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: Colors.black),
errorWidget: (context, url, error) => CachedNetworkImage(
imageUrl: 'https://img.youtube.com/vi/$videoId/hqdefault.jpg',
fit: BoxFit.cover,
),
),
//
Container(
color: Colors.black.withValues(alpha: 0.3),
child: Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(28),
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 28,
),
),
),
),
],
),
),
),
),
),
],
),
);
}
}
///
class _CreditSection extends StatelessWidget {
final List<String> lyricist;
final List<String> composer;
final List<String> arranger;
const _CreditSection({
required this.lyricist,
required this.composer,
required this.arranger,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'크레딧',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
//
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.divider.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
if (lyricist.isNotEmpty)
_CreditItem(
icon: LucideIcons.mic2,
label: '작사',
credits: lyricist,
),
if (composer.isNotEmpty) ...[
if (lyricist.isNotEmpty) const SizedBox(height: 16),
_CreditItem(
icon: LucideIcons.music,
label: '작곡',
credits: composer,
),
],
if (arranger.isNotEmpty) ...[
if (lyricist.isNotEmpty || composer.isNotEmpty) const SizedBox(height: 16),
_CreditItem(
icon: LucideIcons.user,
label: '편곡',
credits: arranger,
),
],
],
),
),
],
),
);
}
}
///
class _CreditItem extends StatelessWidget {
final IconData icon;
final String label;
final List<String> credits;
const _CreditItem({
required this.icon,
required this.label,
required this.credits,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
),
],
),
child: Icon(icon, size: 14, color: AppColors.textSecondary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textTertiary,
),
),
const SizedBox(height: 2),
...credits.map((credit) => Text(
credit,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
)),
],
),
),
],
);
}
}
///
class _LyricsSection extends StatelessWidget {
final String? lyrics;
final bool showFull;
final VoidCallback onToggle;
const _LyricsSection({
required this.lyrics,
required this.showFull,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'가사',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
//
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.divider.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: lyrics != null && lyrics!.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedCrossFade(
firstChild: Text(
lyrics!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.8,
),
textAlign: TextAlign.left,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
secondChild: Text(
lyrics!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.8,
),
textAlign: TextAlign.left,
),
crossFadeState: showFull
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
const SizedBox(height: 12),
GestureDetector(
onTap: onToggle,
child: Container(
padding: const EdgeInsets.only(top: 12),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.divider),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
showFull ? '접기' : '더보기',
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
Icon(
showFull ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 16,
color: AppColors.textSecondary,
),
],
),
),
),
],
)
: Column(
children: [
const SizedBox(height: 16),
Icon(
LucideIcons.mic2,
size: 36,
color: AppColors.textTertiary.withValues(alpha: 0.3),
),
const SizedBox(height: 8),
const Text(
'가사 정보가 없습니다',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
),
const SizedBox(height: 16),
],
),
),
],
),
);
}
}

View file

@ -2,95 +2,61 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/constants.dart';
/// ( + + )
class MainShell extends StatefulWidget {
class MainShell extends StatelessWidget {
final Widget child;
const MainShell({super.key, required this.child});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
DateTime? _lastBackPressed;
/// -
Future<bool> _onWillPop() async {
final now = DateTime.now();
if (_lastBackPressed == null || now.difference(_lastBackPressed!) > const Duration(seconds: 2)) {
_lastBackPressed = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('한 번 더 누르면 종료됩니다'),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
return false;
}
//
SystemNavigator.pop();
return true;
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final isMembersPage = location == '/members';
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
await _onWillPop();
},
child: Scaffold(
backgroundColor: AppColors.background,
// () - ( )
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: isMembersPage
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: SafeArea(
child: SizedBox(
height: 56,
child: Center(
child: Text(
_getTitle(context),
style: const TextStyle(
fontFamily: 'Pretendard',
color: AppColors.primary,
fontSize: 20,
fontWeight: FontWeight.bold,
return Scaffold(
backgroundColor: AppColors.background,
// () - ( )
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: isMembersPage
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: SafeArea(
child: SizedBox(
height: 56,
child: Center(
child: Text(
_getTitle(context),
style: const TextStyle(
fontFamily: 'Pretendard',
color: AppColors.primary,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
//
body: widget.child,
//
bottomNavigationBar: const _BottomNavBar(),
),
//
body: child,
//
bottomNavigationBar: const _BottomNavBar(),
);
}

View file

@ -5,18 +5,12 @@
import FlutterMacOS
import Foundation
import package_info_plus
import path_provider_foundation
import sqflite_darwin
import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}

View file

@ -73,14 +73,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
chewie:
dependency: "direct main"
description:
name: chewie
sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca"
url: "https://pub.dev"
source: hosted
version: "1.13.0"
cli_config:
dependency: transitive
description:
@ -129,14 +121,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -145,14 +129,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: "direct main"
description:
@ -214,14 +190,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_downloader:
dependency: "direct main"
description:
name: flutter_downloader
sha256: "93a9ddbd561f8a3f5483b4189453fba145a0a1014a88143c96a966296b78a118"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
flutter_lints:
dependency: "direct dev"
description:
@ -280,14 +248,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "17.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@ -416,22 +376,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
modal_bottom_sheet:
dependency: "direct main"
description:
name: modal_bottom_sheet
sha256: eac66ef8cb0461bf069a38c5eb0fa728cee525a531a8304bd3f7b2185407c67e
url: "https://pub.dev"
source: hosted
version: "3.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble:
dependency: transitive
description:
@ -456,22 +400,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
@ -536,54 +464,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@ -624,14 +504,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
@ -941,54 +813,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
url: "https://pub.dev"
source: hosted
version: "2.9.1"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
url: "https://pub.dev"
source: hosted
version: "2.8.9"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
vm_service:
dependency: transitive
description:
@ -997,22 +821,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
wakelock_plus:
dependency: transitive
description:
name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
watcher:
dependency: transitive
description:
@ -1053,14 +861,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:

View file

@ -44,12 +44,6 @@ dependencies:
lucide_icons: ^0.257.0
flutter_svg: ^2.0.17
url_launcher: ^6.3.1
flutter_downloader: ^1.11.8
permission_handler: ^11.3.1
modal_bottom_sheet: ^3.0.0
video_thumbnail: ^0.5.3
video_player: ^2.9.2
chewie: ^1.8.5
dev_dependencies:
flutter_test:

View file

@ -6,12 +6,9 @@
#include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -3,7 +3,6 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
url_launcher_windows
)

1
app_expo_backup Submodule

@ -0,0 +1 @@
Subproject commit bf6b7f76369b47437a3047615334b19f56037cba

View file

@ -12,7 +12,6 @@
"bcrypt": "^6.0.0",
"dayjs": "^1.11.19",
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.3",
"inko": "^1.1.1",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3",
@ -2056,11 +2055,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -2560,20 +2554,6 @@
"node": ">= 0.8"
}
},
"node_modules/fluent-ffmpeg": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"async": "^0.2.9",
"which": "^1.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2869,12 +2849,6 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -3806,18 +3780,6 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View file

@ -19,7 +19,6 @@
"mysql2": "^3.11.0",
"node-cron": "^4.2.1",
"rss-parser": "^3.13.0",
"sharp": "^0.33.5",
"fluent-ffmpeg": "^2.1.3"
"sharp": "^0.33.5"
}
}

View file

@ -3,10 +3,6 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import multer from "multer";
import sharp from "sharp";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs/promises";
import os from "os";
import path from "path";
import {
S3Client,
PutObjectCommand,
@ -559,7 +555,7 @@ router.get("/albums/:albumId/teasers", async (req, res) => {
// 티저 조회
const [teasers] = await pool.query(
`SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type
`SELECT id, original_url, medium_url, thumb_url, sort_order, media_type
FROM album_teasers
WHERE album_id = ?
ORDER BY sort_order ASC`,
@ -599,7 +595,7 @@ router.delete(
const filename = teaser.original_url.split("/").pop();
const basePath = `album/${teaser.folder_name}/teaser`;
// RustFS에서 썸네일 삭제 (3가지 크기 모두)
// RustFS에서 삭제 (3가지 크기 모두)
const sizes = ["original", "medium_800", "thumb_400"];
for (const size of sizes) {
try {
@ -614,21 +610,6 @@ router.delete(
}
}
// 비디오 파일 삭제 (video_url이 있는 경우)
if (teaser.video_url) {
const videoFilename = teaser.video_url.split("/").pop();
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/video/${videoFilename}`,
})
);
} catch (s3Error) {
console.error("S3 비디오 삭제 오류:", s3Error);
}
}
// 티저 삭제
await connection.query("DELETE FROM album_teasers WHERE id = ?", [
teaserId,
@ -708,7 +689,7 @@ router.post(
// 진행률 전송
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
let originalUrl, mediumUrl, thumbUrl, videoUrl;
let originalUrl, mediumUrl, thumbUrl;
let originalBuffer, originalMeta;
// 컨셉 포토: photo/, 티저: teaser/
@ -717,91 +698,19 @@ router.post(
if (isVideo) {
// ===== 비디오 파일 처리 (티저 전용) =====
const tempDir = os.tmpdir();
const tempVideoPath = path.join(tempDir, `video_${Date.now()}.mp4`);
const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`);
const thumbFilename = `${orderNum}.webp`;
// 원본 MP4만 업로드 (리사이즈 없음)
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/original/${filename}`,
Body: file.buffer,
ContentType: "video/mp4",
})
);
try {
// 1. 임시 파일로 MP4 저장
await fs.writeFile(tempVideoPath, file.buffer);
// 2. ffmpeg로 첫 프레임 추출 (썸네일)
await new Promise((resolve, reject) => {
ffmpeg(tempVideoPath)
.screenshots({
timestamps: ["00:00:00.001"],
filename: path.basename(tempThumbPath),
folder: tempDir,
})
.on("end", resolve)
.on("error", reject);
});
// 3. 추출된 썸네일을 Sharp로 3가지 크기로 변환
const thumbBuffer = await fs.readFile(tempThumbPath);
const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([
sharp(thumbBuffer).webp({ lossless: true }).toBuffer(),
sharp(thumbBuffer)
.resize(800, null, { withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
sharp(thumbBuffer)
.resize(400, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer(),
]);
// 4. 썸네일 이미지들과 MP4 업로드 (병렬)
await Promise.all([
// 썸네일 original
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/original/${thumbFilename}`,
Body: origBuf,
ContentType: "image/webp",
})
),
// 썸네일 medium
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/medium_800/${thumbFilename}`,
Body: medium800Buffer,
ContentType: "image/webp",
})
),
// 썸네일 thumb
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/thumb_400/${thumbFilename}`,
Body: thumb400Buffer,
ContentType: "image/webp",
})
),
// 원본 MP4
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/video/${filename}`,
Body: file.buffer,
ContentType: "video/mp4",
})
),
]);
// 5. URL 설정 (썸네일은 WebP, 비디오는 MP4)
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${thumbFilename}`;
mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${thumbFilename}`;
thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${thumbFilename}`;
videoUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/video/${filename}`;
} finally {
// 임시 파일 정리
await fs.unlink(tempVideoPath).catch(() => {});
await fs.unlink(tempThumbPath).catch(() => {});
}
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
mediumUrl = originalUrl; // 비디오는 원본만 사용
thumbUrl = originalUrl;
} else {
// ===== 이미지 파일 처리 =====
// Sharp로 이미지 처리 (병렬)
@ -861,14 +770,13 @@ router.post(
const mediaType = isVideo ? "video" : "image";
const [result] = await connection.query(
`INSERT INTO album_teasers
(album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(album_id, original_url, medium_url, thumb_url, sort_order, media_type)
VALUES (?, ?, ?, ?, ?, ?)`,
[
albumId,
originalUrl,
mediumUrl,
thumbUrl,
videoUrl || null,
nextOrder + i,
mediaType,
]
@ -911,7 +819,6 @@ router.post(
original_url: originalUrl,
medium_url: mediumUrl,
thumb_url: thumbUrl,
video_url: videoUrl || null,
filename,
media_type: isVideo ? "video" : "image",
});

View file

@ -12,9 +12,9 @@ async function getAlbumDetails(album) {
);
album.tracks = tracks;
// 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함)
// 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함)
const [teasers] = await pool.query(
"SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
"SELECT original_url, medium_url, thumb_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
[album.id]
);
album.teasers = teasers;

View file

@ -19,7 +19,7 @@ services:
image: node:20-alpine
container_name: fromis9-backend
working_dir: /app
command: sh -c "apk add --no-cache ffmpeg && npm install && node server.js"
command: sh -c "npm install && node server.js"
env_file:
- .env
environment:

View file

@ -217,25 +217,31 @@ function MobileAlbumDetail() {
<div
key={index}
onClick={() => openLightbox(
album.teasers.map(t =>
t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url
),
album.teasers.map(t => t.original_url),
index,
{ teasers: album.teasers, showNav: true }
)}
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
>
<img
src={teaser.thumb_url || teaser.original_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
{teaser.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
{teaser.media_type === 'video' ? (
<>
<video
src={teaser.original_url}
className="w-full h-full object-cover"
muted
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
</div>
</div>
</div>
</>
) : (
<img
src={teaser.thumb_url || teaser.original_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
)}
</div>
))}

View file

@ -1381,8 +1381,7 @@ function AdminAlbumPhotos() {
>
{teaser.media_type === 'video' ? (
<video
src={teaser.video_url || teaser.original_url}
poster={teaser.thumb_url}
src={teaser.original_url}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
muted
loop

View file

@ -295,29 +295,33 @@ function AlbumDetail() {
key={index}
onClick={() => setLightbox({
open: true,
images: album.teasers.map(t =>
t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url
),
images: album.teasers.map(t => t.original_url),
index,
teasers: album.teasers // media_type
})}
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
>
<>
<img
src={teaser.thumb_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
{/* 비디오 아이콘 오버레이 */}
{teaser.media_type === 'video' && (
{teaser.media_type === 'video' ? (
<>
<video
src={teaser.original_url}
className="w-full h-full object-cover"
muted
/>
{/* 비디오 아이콘 오버레이 */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
</div>
</div>
)}
</>
</>
) : (
<img
src={teaser.thumb_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
)}
</div>
))}
</div>