steveyehtw commited on
Commit
5dd93eb
·
1 Parent(s): eb58522

feat: add Android APK simulation & download support with Appetize.io

Browse files
projects/DEMO/Student1/Android測試專案/appetize.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ b_olthggzkkmb43hyiyzcij32sye
projects/DEMO/Student1/Android測試專案/test_app.apk ADDED
@@ -0,0 +1 @@
 
 
1
+ This is a dummy APK file content to test the arcade hub APK download and scanner features.
server.py CHANGED
@@ -46,15 +46,24 @@ def scan_projects():
46
  for project_name in sorted(os.listdir(student_path)):
47
  project_dir = os.path.join(student_path, project_name)
48
  if os.path.isdir(project_dir) and not project_name.startswith('.'):
49
- # Check type priority: 1. Python, 2. HTML, 3. External Link
50
  main_py = os.path.join(project_dir, "main.py")
51
  index_html = os.path.join(project_dir, "index.html")
52
  link_txt = os.path.join(project_dir, "link.txt")
53
 
 
 
 
 
 
 
 
54
  if os.path.exists(main_py):
55
  projects.append({"name": project_name, "type": "python", "url": ""})
56
  elif os.path.exists(index_html):
57
  projects.append({"name": project_name, "type": "html", "url": ""})
 
 
58
  elif os.path.exists(link_txt):
59
  try:
60
  with open(link_txt, "r", encoding="utf-8") as f:
@@ -260,11 +269,41 @@ async def run_project_page(request: Request, student_token: str, project_name: s
260
  proj_dir = os.path.join(PROJECTS_DIR, real_class, real_student, project_name)
261
  proj_type = "unknown"
262
  external_url = ""
 
 
 
263
 
 
 
 
 
 
 
 
264
  if os.path.exists(os.path.join(proj_dir, "main.py")):
265
  proj_type = "python"
266
  elif os.path.exists(os.path.join(proj_dir, "index.html")):
267
  proj_type = "html"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  elif os.path.exists(os.path.join(proj_dir, "link.txt")):
269
  proj_type = "external"
270
  try:
@@ -280,7 +319,10 @@ async def run_project_page(request: Request, student_token: str, project_name: s
280
  "student_token": student_token,
281
  "project_name": project_name,
282
  "project_type": proj_type,
283
- "external_url": external_url
 
 
 
284
  })
285
 
286
  @app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse)
 
46
  for project_name in sorted(os.listdir(student_path)):
47
  project_dir = os.path.join(student_path, project_name)
48
  if os.path.isdir(project_dir) and not project_name.startswith('.'):
49
+ # Check type priority: 1. Python, 2. HTML, 3. APK, 4. External Link
50
  main_py = os.path.join(project_dir, "main.py")
51
  index_html = os.path.join(project_dir, "index.html")
52
  link_txt = os.path.join(project_dir, "link.txt")
53
 
54
+ apk_files = []
55
+ if os.path.isdir(project_dir):
56
+ try:
57
+ apk_files = [f for f in os.listdir(project_dir) if f.endswith(".apk") and not f.startswith('.')]
58
+ except Exception:
59
+ pass
60
+
61
  if os.path.exists(main_py):
62
  projects.append({"name": project_name, "type": "python", "url": ""})
63
  elif os.path.exists(index_html):
64
  projects.append({"name": project_name, "type": "html", "url": ""})
65
+ elif apk_files:
66
+ projects.append({"name": project_name, "type": "apk", "url": apk_files[0]})
67
  elif os.path.exists(link_txt):
68
  try:
69
  with open(link_txt, "r", encoding="utf-8") as f:
 
269
  proj_dir = os.path.join(PROJECTS_DIR, real_class, real_student, project_name)
270
  proj_type = "unknown"
271
  external_url = ""
272
+ apk_name = ""
273
+ apk_size = ""
274
+ appetize_key = ""
275
 
276
+ apk_files = []
277
+ if os.path.isdir(proj_dir):
278
+ try:
279
+ apk_files = [f for f in os.listdir(proj_dir) if f.endswith(".apk") and not f.startswith('.')]
280
+ except Exception:
281
+ pass
282
+
283
  if os.path.exists(os.path.join(proj_dir, "main.py")):
284
  proj_type = "python"
285
  elif os.path.exists(os.path.join(proj_dir, "index.html")):
286
  proj_type = "html"
287
+ elif apk_files:
288
+ proj_type = "apk"
289
+ apk_name = apk_files[0]
290
+ # Check size of the apk file
291
+ try:
292
+ size_bytes = os.path.getsize(os.path.join(proj_dir, apk_name))
293
+ if size_bytes < 1024 * 1024:
294
+ apk_size = f"{size_bytes / 1024:.1f} KB"
295
+ else:
296
+ apk_size = f"{size_bytes / (1024 * 1024):.1f} MB"
297
+ except Exception:
298
+ pass
299
+ # Check appetize.txt
300
+ appetize_path = os.path.join(proj_dir, "appetize.txt")
301
+ if os.path.exists(appetize_path):
302
+ try:
303
+ with open(appetize_path, "r", encoding="utf-8") as f:
304
+ appetize_key = f.read().strip()
305
+ except Exception:
306
+ pass
307
  elif os.path.exists(os.path.join(proj_dir, "link.txt")):
308
  proj_type = "external"
309
  try:
 
319
  "student_token": student_token,
320
  "project_name": project_name,
321
  "project_type": proj_type,
322
+ "external_url": external_url,
323
+ "apk_name": apk_name,
324
+ "apk_size": apk_size,
325
+ "appetize_key": appetize_key
326
  })
327
 
328
  @app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse)
templates/index.html CHANGED
@@ -448,6 +448,11 @@
448
  border-color: rgba(16, 185, 129, 0.15);
449
  }
450
 
 
 
 
 
 
451
  .project-meta {
452
  display: flex;
453
  align-items: center;
@@ -496,6 +501,12 @@
496
  border: 1px solid rgba(16, 185, 129, 0.15);
497
  }
498
 
 
 
 
 
 
 
499
  /* Action launch button */
500
  .btn-launch {
501
  border: none;
@@ -545,6 +556,13 @@
545
  background: #047857;
546
  }
547
 
 
 
 
 
 
 
 
548
  /* Placeholder */
549
  .empty-placeholder {
550
  display: flex;
@@ -967,6 +985,11 @@
967
  tagClass = "tag-html";
968
  hoverClass = "hover-html";
969
  btnClass = "btn-launch-html";
 
 
 
 
 
970
  }
971
 
972
  const card = document.createElement('div');
 
448
  border-color: rgba(16, 185, 129, 0.15);
449
  }
450
 
451
+ .project-card.hover-apk:hover {
452
+ border-left-color: #a855f7;
453
+ border-color: rgba(168, 85, 247, 0.15);
454
+ }
455
+
456
  .project-meta {
457
  display: flex;
458
  align-items: center;
 
501
  border: 1px solid rgba(16, 185, 129, 0.15);
502
  }
503
 
504
+ .tag-apk {
505
+ background: rgba(168, 85, 247, 0.08);
506
+ color: #d8b4fe;
507
+ border: 1px solid rgba(168, 85, 247, 0.15);
508
+ }
509
+
510
  /* Action launch button */
511
  .btn-launch {
512
  border: none;
 
556
  background: #047857;
557
  }
558
 
559
+ .btn-launch-apk {
560
+ background: var(--accent);
561
+ }
562
+ .btn-launch-apk:hover {
563
+ background: #9333ea;
564
+ }
565
+
566
  /* Placeholder */
567
  .empty-placeholder {
568
  display: flex;
 
985
  tagClass = "tag-html";
986
  hoverClass = "hover-html";
987
  btnClass = "btn-launch-html";
988
+ } else if (proj.type === "apk") {
989
+ tagText = "Android App (APK)";
990
+ tagClass = "tag-apk";
991
+ hoverClass = "hover-apk";
992
+ btnClass = "btn-launch-apk";
993
  }
994
 
995
  const card = document.createElement('div');
templates/player.html CHANGED
@@ -460,6 +460,246 @@
460
  padding: 1rem;
461
  }
462
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  </style>
464
  </head>
465
  <body>
@@ -501,9 +741,9 @@
501
  </div>
502
  </div>
503
 
504
- <!-- 2. Side Panel (Python Only) -->
505
  <div class="control-panel" id="side-panel">
506
- <div class="device-section">
507
  <h3 class="device-title">虛擬硬體設定</h3>
508
  <button class="btn-webcam" id="btn-toggle-webcam">
509
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
@@ -522,7 +762,7 @@
522
  </div>
523
  </div>
524
 
525
- <div class="terminal-section">
526
  <div class="terminal-header-bar">
527
  <h3 class="device-title">終端機日誌</h3>
528
  <button class="btn-clear" onclick="clearConsole()">清除</button>
@@ -533,6 +773,51 @@
533
  <input type="text" id="terminal-input-field" placeholder="輸入資料後按 Enter 傳送..." autocomplete="off" />
534
  </div>
535
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  </div>
537
  </div>
538
 
@@ -547,6 +832,9 @@
547
  const projectName = "{{ project_name }}";
548
  const projectType = "{{ project_type }}";
549
  const externalUrl = "{{ external_url }}";
 
 
 
550
 
551
  const viewportFrame = document.getElementById("viewport-frame");
552
  const focusPrompt = document.getElementById("focus-prompt-el");
@@ -592,6 +880,84 @@
592
  focusPrompt.style.display = "flex";
593
  setupPythonControls(img);
594
  connectWebSocket();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  } else {
596
  // Web based game (HTML or External Link)
597
  // Hide side controls and console log panel, render full viewport width
 
460
  padding: 1rem;
461
  }
462
  }
463
+
464
+ /* APK Card Tag / Button */
465
+ .btn-launch-apk {
466
+ background: var(--accent) !important;
467
+ }
468
+ .btn-launch-apk:hover {
469
+ background: #9333ea !important;
470
+ transform: translateY(-1px);
471
+ box-shadow: 0 4px 12px rgba(168, 85, 247, 0.25);
472
+ }
473
+
474
+ /* Smartphone Mockup Container */
475
+ .phone-mockup {
476
+ width: 320px;
477
+ height: 640px;
478
+ background: #000000;
479
+ border: 12px solid #1f2937;
480
+ border-radius: 40px;
481
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8), 0 0 0 2px rgba(255, 255, 255, 0.05);
482
+ display: flex;
483
+ flex-direction: column;
484
+ position: relative;
485
+ overflow: hidden;
486
+ flex-shrink: 0;
487
+ transition: all 0.3s ease;
488
+ }
489
+
490
+ /* Notch / Island bezel */
491
+ .phone-mockup::before {
492
+ content: '';
493
+ position: absolute;
494
+ top: 0;
495
+ left: 50%;
496
+ transform: translateX(-50%);
497
+ width: 140px;
498
+ height: 25px;
499
+ background: #1f2937;
500
+ border-bottom-left-radius: 18px;
501
+ border-bottom-right-radius: 18px;
502
+ z-index: 10;
503
+ }
504
+
505
+ .phone-speaker {
506
+ position: absolute;
507
+ top: 5px;
508
+ left: 50%;
509
+ transform: translateX(-50%);
510
+ width: 40px;
511
+ height: 4px;
512
+ background: #374151;
513
+ border-radius: 2px;
514
+ z-index: 11;
515
+ }
516
+
517
+ .phone-camera {
518
+ position: absolute;
519
+ top: 4px;
520
+ left: calc(50% + 30px);
521
+ width: 6px;
522
+ height: 6px;
523
+ background: #111827;
524
+ border-radius: 50%;
525
+ z-index: 11;
526
+ }
527
+
528
+ .phone-status-bar {
529
+ width: 100%;
530
+ height: 25px;
531
+ padding: 0 1.5rem;
532
+ display: flex;
533
+ justify-content: space-between;
534
+ align-items: center;
535
+ font-size: 0.7rem;
536
+ color: rgba(255, 255, 255, 0.6);
537
+ position: absolute;
538
+ top: 0;
539
+ left: 0;
540
+ z-index: 5;
541
+ box-sizing: border-box;
542
+ }
543
+
544
+ .phone-screen {
545
+ flex-grow: 1;
546
+ background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%);
547
+ display: flex;
548
+ flex-direction: column;
549
+ align-items: center;
550
+ justify-content: center;
551
+ padding: 1.5rem;
552
+ color: #ffffff;
553
+ position: relative;
554
+ text-align: center;
555
+ gap: 1.5rem;
556
+ box-sizing: border-box;
557
+ }
558
+
559
+ .phone-screen-iframe {
560
+ width: 100%;
561
+ height: 100%;
562
+ border: none;
563
+ background: #000;
564
+ border-radius: 28px;
565
+ }
566
+
567
+ .android-icon-wrapper {
568
+ width: 80px;
569
+ height: 80px;
570
+ background: rgba(168, 85, 247, 0.15);
571
+ border: 2px solid rgba(168, 85, 247, 0.3);
572
+ border-radius: 50%;
573
+ display: flex;
574
+ align-items: center;
575
+ justify-content: center;
576
+ animation: pulse-glow 2s infinite ease-in-out;
577
+ }
578
+
579
+ @keyframes pulse-glow {
580
+ 0%, 100% {
581
+ transform: scale(1);
582
+ box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4);
583
+ }
584
+ 50% {
585
+ transform: scale(1.05);
586
+ box-shadow: 0 0 20px 5px rgba(168, 85, 247, 0.2);
587
+ }
588
+ }
589
+
590
+ /* APK Side Panel Layout and Elements */
591
+ .apk-download-card {
592
+ background: rgba(255, 255, 255, 0.02);
593
+ border: 1px solid var(--border-color);
594
+ padding: 1.2rem;
595
+ border-radius: 12px;
596
+ display: flex;
597
+ flex-direction: column;
598
+ gap: 0.8rem;
599
+ align-items: center;
600
+ text-align: center;
601
+ box-sizing: border-box;
602
+ }
603
+
604
+ .apk-info-text {
605
+ display: flex;
606
+ flex-direction: column;
607
+ gap: 2px;
608
+ }
609
+
610
+ .apk-file-name {
611
+ font-weight: 700;
612
+ font-size: 0.95rem;
613
+ color: #ffffff;
614
+ word-break: break-all;
615
+ }
616
+
617
+ .apk-file-size {
618
+ font-size: 0.75rem;
619
+ color: var(--text-muted);
620
+ }
621
+
622
+ .qr-section {
623
+ display: flex;
624
+ flex-direction: column;
625
+ align-items: center;
626
+ gap: 0.6rem;
627
+ text-align: center;
628
+ border-top: 1px solid var(--border-color);
629
+ padding-top: 1.5rem;
630
+ box-sizing: border-box;
631
+ }
632
+
633
+ .qr-code-wrapper {
634
+ background: #ffffff;
635
+ padding: 10px;
636
+ border-radius: 12px;
637
+ width: 160px;
638
+ height: 160px;
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: center;
642
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
643
+ box-sizing: border-box;
644
+ }
645
+
646
+ .qr-code-wrapper img {
647
+ width: 140px;
648
+ height: 140px;
649
+ display: block;
650
+ }
651
+
652
+ .qr-tip {
653
+ font-size: 0.75rem;
654
+ color: var(--text-muted);
655
+ line-height: 1.4;
656
+ max-width: 280px;
657
+ }
658
+
659
+ .guide-section {
660
+ border-top: 1px solid var(--border-color);
661
+ padding-top: 1.5rem;
662
+ display: flex;
663
+ flex-direction: column;
664
+ gap: 0.8rem;
665
+ box-sizing: border-box;
666
+ }
667
+
668
+ .guide-item {
669
+ display: flex;
670
+ gap: 12px;
671
+ align-items: flex-start;
672
+ }
673
+
674
+ .guide-step-number {
675
+ background: rgba(168, 85, 247, 0.15);
676
+ color: #c084fc;
677
+ width: 22px;
678
+ height: 22px;
679
+ border-radius: 50%;
680
+ display: flex;
681
+ align-items: center;
682
+ justify-content: center;
683
+ font-size: 0.75rem;
684
+ font-weight: 700;
685
+ flex-shrink: 0;
686
+ }
687
+
688
+ .guide-text {
689
+ font-size: 0.82rem;
690
+ color: var(--text-muted);
691
+ line-height: 1.4;
692
+ }
693
+
694
+ .guide-link {
695
+ color: #c084fc;
696
+ text-decoration: underline;
697
+ transition: color 0.2s;
698
+ }
699
+
700
+ .guide-link:hover {
701
+ color: #d8b4fe;
702
+ }
703
  </style>
704
  </head>
705
  <body>
 
741
  </div>
742
  </div>
743
 
744
+ <!-- 2. Side Panel -->
745
  <div class="control-panel" id="side-panel">
746
+ <div class="device-section" id="python-device-section">
747
  <h3 class="device-title">虛擬硬體設定</h3>
748
  <button class="btn-webcam" id="btn-toggle-webcam">
749
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
 
762
  </div>
763
  </div>
764
 
765
+ <div class="terminal-section" id="python-terminal-section">
766
  <div class="terminal-header-bar">
767
  <h3 class="device-title">終端機日誌</h3>
768
  <button class="btn-clear" onclick="clearConsole()">清除</button>
 
773
  <input type="text" id="terminal-input-field" placeholder="輸入資料後按 Enter 傳送..." autocomplete="off" />
774
  </div>
775
  </div>
776
+
777
+ <!-- APK Info Section -->
778
+ <div id="apk-info-section" style="display: none; padding: 1.5rem; flex-direction: column; gap: 1.5rem; overflow-y: auto; height: 100%;">
779
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
780
+ <h3 class="device-title">Android APK 專案</h3>
781
+ <div class="apk-download-card">
782
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="1.5">
783
+ <path d="M12 2v16m0 0l-4-4m4 4l4-4M4 22h16" stroke-linecap="round" stroke-linejoin="round"/>
784
+ </svg>
785
+ <div class="apk-info-text">
786
+ <div class="apk-file-name" id="apk-file-label">app.apk</div>
787
+ <div class="apk-file-size" id="apk-size-label">大小估算中...</div>
788
+ </div>
789
+ <a id="btn-apk-download" href="#" class="btn-launch btn-launch-apk" style="text-decoration: none; font-size: 0.85rem;" download>
790
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 5v14M5 12h14"/></svg>
791
+ 下載 APK 檔案
792
+ </a>
793
+ </div>
794
+ </div>
795
+
796
+ <div class="qr-section">
797
+ <h3 class="device-title">掃描二維碼安裝</h3>
798
+ <div class="qr-code-wrapper">
799
+ <img id="apk-qr-image" src="" alt="QR Code" />
800
+ </div>
801
+ <span class="qr-tip">使用 Android 裝置掃描此條碼,即可直接下載並安裝此應用程式</span>
802
+ </div>
803
+
804
+ <div class="guide-section">
805
+ <h3 class="device-title">安裝與模擬說明</h3>
806
+
807
+ <div class="guide-item">
808
+ <div class="guide-step-number">1</div>
809
+ <p class="guide-text">下載 APK 檔案後,在裝置上點擊安裝。若系統提示「安全性警告」,請允許瀏覽器「安裝未知來源的應用程式」。</p>
810
+ </div>
811
+
812
+ <div class="guide-item" style="border-top: 1px dashed var(--border-color); padding-top: 0.8rem; margin-top: 0.8rem;">
813
+ <div class="guide-step-number">2</div>
814
+ <p class="guide-text">
815
+ <strong>啟用網頁模擬器</strong>:<br>
816
+ 本平台支援整合 <a href="https://appetize.io" target="_blank" class="guide-link">Appetize.io</a>。將您的 APK 上傳至該網站後,在專案目錄下建立 <code>appetize.txt</code> 寫入 Public Key,即可在此頁面直接操作模擬器。
817
+ </p>
818
+ </div>
819
+ </div>
820
+ </div>
821
  </div>
822
  </div>
823
 
 
832
  const projectName = "{{ project_name }}";
833
  const projectType = "{{ project_type }}";
834
  const externalUrl = "{{ external_url }}";
835
+ const apkName = "{{ apk_name }}";
836
+ const apkSize = "{{ apk_size }}";
837
+ const appetizeKey = "{{ appetize_key }}";
838
 
839
  const viewportFrame = document.getElementById("viewport-frame");
840
  const focusPrompt = document.getElementById("focus-prompt-el");
 
880
  focusPrompt.style.display = "flex";
881
  setupPythonControls(img);
882
  connectWebSocket();
883
+ } else if (projectType === "apk") {
884
+ // Hide Python sections, show APK sections
885
+ document.getElementById("python-device-section").style.display = "none";
886
+ document.getElementById("python-terminal-section").style.display = "none";
887
+ statusWrapper.style.display = "none";
888
+
889
+ const apkInfoSec = document.getElementById("apk-info-section");
890
+ apkInfoSec.style.display = "flex";
891
+
892
+ // Setup downloads & sizes
893
+ const dlUrl = window.location.origin + `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/${encodeURIComponent(apkName)}`;
894
+ document.getElementById("btn-apk-download").href = dlUrl;
895
+ document.getElementById("apk-file-label").innerText = apkName;
896
+ document.getElementById("apk-size-label").innerText = apkSize ? `檔案大小: ${apkSize}` : "已偵測到安裝檔";
897
+
898
+ // Generate QR code
899
+ const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(dlUrl)}`;
900
+ document.getElementById("apk-qr-image").src = qrUrl;
901
+
902
+ // Setup Phone mockup
903
+ viewportFrame.classList.add("iframe-mode");
904
+ viewportFrame.style.background = "transparent";
905
+ viewportFrame.style.boxShadow = "none";
906
+ viewportFrame.style.border = "none";
907
+ viewportFrame.style.padding = "0";
908
+
909
+ const phone = document.createElement("div");
910
+ phone.className = "phone-mockup";
911
+
912
+ const speaker = document.createElement("div");
913
+ speaker.className = "phone-speaker";
914
+ phone.appendChild(speaker);
915
+
916
+ const camera = document.createElement("div");
917
+ camera.className = "phone-camera";
918
+ phone.appendChild(camera);
919
+
920
+ const statusBar = document.createElement("div");
921
+ statusBar.className = "phone-status-bar";
922
+ statusBar.innerHTML = `
923
+ <span>14:30</span>
924
+ <div style="display:flex; gap: 4px; align-items:center;">
925
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 19.4c3.9 3.9 10.2 3.9 14.1 0l-1.62-1.62C18.26 16.07 19 14.12 19 12c0-4.97-4.03-9-9-9z"/></svg>
926
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
927
+ </div>
928
+ `;
929
+ phone.appendChild(statusBar);
930
+
931
+ const screen = document.createElement("div");
932
+ screen.className = "phone-screen";
933
+
934
+ if (appetizeKey) {
935
+ const iframe = document.createElement("iframe");
936
+ iframe.className = "phone-screen-iframe";
937
+ iframe.src = `https://appetize.io/embed/${encodeURIComponent(appetizeKey)}?device=pixel4&scale=auto&autoplay=false&orientation=portrait&deviceColor=black`;
938
+ screen.appendChild(iframe);
939
+ } else {
940
+ screen.innerHTML = `
941
+ <div class="android-icon-wrapper">
942
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#c084fc" stroke-width="1.8">
943
+ <path d="M4 10v6m16-6v6M6 18c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9H6v9zm3-13l-1.5-2M15 5l1.5-2M9 13v.01M15 13v.01" stroke-linecap="round" stroke-linejoin="round"/>
944
+ </svg>
945
+ </div>
946
+ <div>
947
+ <h4 style="font-weight: 800; font-size: 1.25rem; color: #ffffff; margin-bottom: 0.5rem; text-shadow: 0 2px 4px rgba(0,0,0,0.3);">${projectName}</h4>
948
+ <p style="font-size: 0.8rem; color: rgba(255,255,255,0.7); line-height: 1.4; padding: 0 1rem;">本專案為 Android 應用程式</p>
949
+ </div>
950
+ <div style="background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.05); padding: 0.8rem; border-radius: 12px; width: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 8px;">
951
+ <span style="font-size: 0.72rem; color: var(--text-muted); line-height: 1.3;">掃描此條碼以安裝至您的實體手機</span>
952
+ <img src="${qrUrl}" alt="QR Code" style="width: 100px; height: 100px; border-radius: 6px; border: 4px solid #ffffff; display: block;" />
953
+ </div>
954
+ <a href="${dlUrl}" class="btn-launch btn-launch-apk" style="text-decoration: none; width: 100%; font-size: 0.82rem;" download>
955
+ 下載 APK 安裝檔
956
+ </a>
957
+ `;
958
+ }
959
+ phone.appendChild(screen);
960
+ viewportFrame.appendChild(phone);
961
  } else {
962
  // Web based game (HTML or External Link)
963
  // Hide side controls and console log panel, render full viewport width